Coverage for src/fjc_biographies/scores.py: 100%
75 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-22 22:47 +0000
1"""Adds scores of judges' ideology to a roster.
3Two kinds of scores can be added to a roster made by `parser.build_roster`:
5* `add_jcs` adds each appointment's Judicial Common Space score ("jcs"),
6 computed by the judicial-common-space package (Epstein, Martin, Segal, and
7 Westerland, "The Judicial Common Space," 23 J.L. Econ. & Org. 303 (2007)):
8 the NOMINATE score of the judge's home-state senators of the president's
9 party, or else of the president, and for a justice, the justice's
10 Martin-Quinn score in the first term of the appointment, put on the same
11 scale.
12* `add_martin_quinn` adds the Martin-Quinn scores of the justices of the
13 Supreme Court, which are estimated from their votes in each term
14 (https://mqscores.wustl.edu): for each appointment to the Supreme Court,
15 the scores of its first and last terms, their mean, and the number of
16 terms. `martin_quinn_terms` returns every justice's score in every term.
18The scores come from files that are downloaded when they are needed, into the
19data folder of judicial-common-space (see
20`judicial_common_space.utilities.data_folder`, which the
21`JUDICIAL_COMMON_SPACE_DATA_DIR` environment variable can set, so that
22courtpy and judicial-common-space share the downloads). The Martin-Quinn
23website sometimes refuses or fails to answer automated requests. Then save
24"justices.csv" from https://mqscores.wustl.edu/measures.php in a browser and
25pass its path as `martin_quinn`.
27Contents:
28 add_jcs: adds each appointment's Judicial Common Space score.
29 add_martin_quinn: adds the Martin-Quinn scores of each appointment to the
30 Supreme Court.
31 martin_quinn_terms: returns each justice's Martin-Quinn score in each
32 term.
34"""
36from __future__ import annotations
38import logging
39import pathlib
41import judicial_common_space as jcs
42import pandas as pd
44from . import downloader
46logger = logging.getLogger(__name__)
48# The files of Voteview and congress-legislators that the Judicial Common
49# Space scores of judges are computed from.
50_POLITICS: tuple[str, ...] = (
51 'congress', 'legislators_historical', 'legislators_current')
52# The columns that `add_martin_quinn` adds, by the summary of an
53# appointment's terms that each is.
54_MQ_COLUMNS: dict[str, str] = {
55 'mq_first': 'first', 'mq_last': 'last', 'mq_mean': 'mean',
56 'mq_terms': 'count'}
59def add_jcs(
60 roster: pd.DataFrame,
61 folder: pathlib.Path | str | None = None,
62 *,
63 scores_folder: pathlib.Path | str | None = None,
64 martin_quinn: pathlib.Path | str | None = None,
65 update: bool = False) -> pd.DataFrame:
66 """Adds each appointment's Judicial Common Space score as "jcs".
68 The scores are computed by `judicial_common_space.compute` from the
69 FJC's files in the "raw" folder of `folder` (the files that the roster
70 was made from), Voteview's NOMINATE scores, the dates of senators'
71 service, Wikipedia's lists of circuit judges' duty stations (if they
72 can be downloaded), and the justices' Martin-Quinn scores (if they can
73 be found).
75 Args:
76 roster: a roster with a row for each appointment, as
77 `parser.build_roster` makes it.
78 folder: the data folder with the FJC's files. Defaults to None, in
79 which case `downloader.data_folder()` is used.
80 scores_folder: the folder for the other files. Defaults to None, in
81 which case the data folder of judicial-common-space is used.
82 martin_quinn: the path of the file of the justices' Martin-Quinn
83 scores. Defaults to None, in which case the file is downloaded
84 if it is not there. Without it, the justices have no score.
85 update: whether to download the files again even if they are there.
86 Defaults to False.
88 Returns:
89 A new roster with "jcs" after "senate_vote". It is missing for an
90 appointment that has no score (as for one by Herbert Hoover,
91 whom Voteview does not score).
93 Raises:
94 KeyError: if `roster` lacks "nid" or "sequence".
96 """
97 _require(roster, ['nid', 'sequence'])
98 files = jcs.sources.paths(scores_folder)
99 files.update(_fjc_files(folder, update = update))
100 jcs.sources.download(_POLITICS, scores_folder, overwrite = update)
101 try:
102 jcs.sources.download(
103 'duty_stations', scores_folder, overwrite = update)
104 except Exception as error: # noqa: BLE001
105 logger.warning(
106 'could not download the duty stations of circuit judges, so '
107 'judges whose careers show no state have their birth states: %s',
108 error)
109 try:
110 found = _martin_quinn_file(martin_quinn, scores_folder, update = update)
111 except RuntimeError as error:
112 logger.warning('%s, so the justices have no scores', error)
113 found = None
114 scored, _ = jcs.compute(files, martin_quinn = found)
115 keyed = scored[['nid', 'sequence', 'jcs']].assign(
116 nid = pd.to_numeric(scored['nid']).astype('Int64'),
117 sequence = pd.to_numeric(scored['sequence']).astype('Int64'),
118 jcs = pd.to_numeric(scored['jcs']).astype('Float64'))
119 return _insert(roster, keyed, ['jcs'])
122def add_martin_quinn(
123 roster: pd.DataFrame,
124 folder: pathlib.Path | str | None = None,
125 *,
126 scores_folder: pathlib.Path | str | None = None,
127 martin_quinn: pathlib.Path | str | None = None,
128 update: bool = False,
129 terms: pd.DataFrame | None = None) -> pd.DataFrame:
130 """Adds the Martin-Quinn scores of each appointment to the Supreme Court.
132 A justice's terms belong to the appointment that the justice served in
133 then, so William Rehnquist's terms as Chief Justice are those of his
134 second appointment (see `martin_quinn_terms`). The added columns are:
136 | Column | Meaning |
137 | --- | --- |
138 | `mq_first`, `mq_last` | The justice's score in the first and last terms of the appointment. |
139 | `mq_mean` | The mean of the justice's scores in the terms of the appointment. |
140 | `mq_terms` | The number of terms of the appointment that are scored. |
142 They are missing for every other appointment, and for one with no
143 scored terms.
145 Args:
146 roster: a roster with a row for each appointment, as
147 `parser.build_roster` makes it.
148 folder: the data folder with the FJC's files. Defaults to None, in
149 which case `downloader.data_folder()` is used.
150 scores_folder: the folder for the file of Martin-Quinn scores.
151 Defaults to None, in which case the data folder of
152 judicial-common-space is used.
153 martin_quinn: the path of the file of the justices' Martin-Quinn
154 scores ("justices.csv"). Defaults to None, in which case the file
155 is downloaded if it is not there.
156 update: whether to download the files again even if they are there.
157 Defaults to False.
158 terms: the justices' scores in each term, as `martin_quinn_terms`
159 returns them. Defaults to None, in which case they are found
160 from the files (and the other arguments are not used).
162 Returns:
163 A new roster with the columns after "senate_vote" (and "jcs", if it
164 is there).
166 Raises:
167 KeyError: if `roster` lacks "nid" or "sequence".
168 RuntimeError: if there is no file of Martin-Quinn scores and it
169 cannot be downloaded.
171 """
172 _require(roster, ['nid', 'sequence'])
173 if terms is None:
174 terms = martin_quinn_terms(
175 folder, scores_folder = scores_folder,
176 martin_quinn = martin_quinn, update = update)
177 terms = terms.dropna(subset = ['nid', 'sequence']).sort_values(
178 'term', kind = 'stable')
179 grouped = terms.groupby(['nid', 'sequence'])['martin_quinn']
180 summary = pd.DataFrame({
181 column: grouped.agg(how) for column, how in _MQ_COLUMNS.items()})
182 summary = summary.reset_index().astype({
183 'nid': 'Int64', 'sequence': 'Int64', 'mq_first': 'Float64',
184 'mq_last': 'Float64', 'mq_mean': 'Float64', 'mq_terms': 'Int64'})
185 return _insert(roster, summary, list(_MQ_COLUMNS))
188def martin_quinn_terms(
189 folder: pathlib.Path | str | None = None,
190 *,
191 scores_folder: pathlib.Path | str | None = None,
192 martin_quinn: pathlib.Path | str | None = None,
193 update: bool = False) -> pd.DataFrame:
194 """Returns each justice's Martin-Quinn score in each term.
196 Each justice of the Martin-Quinn scores is found among the FJC's
197 appointments to the Supreme Court, and each term is given to the
198 justice's appointment that was serving then, by
199 `judicial_common_space.scores.justices`.
201 Args:
202 folder: the data folder with the FJC's files. Defaults to None, in
203 which case `downloader.data_folder()` is used.
204 scores_folder: the folder for the file of Martin-Quinn scores.
205 Defaults to None, in which case the data folder of
206 judicial-common-space is used.
207 martin_quinn: the path of the file of the justices' Martin-Quinn
208 scores ("justices.csv"). Defaults to None, in which case the file
209 is downloaded if it is not there.
210 update: whether to download the files again even if they are there.
211 Defaults to False.
213 Returns:
214 A table with a row for each justice in each term: "term" (the year
215 in which the term began, in October), "justiceName" (the Supreme
216 Court Database's name for the justice), "judge" and "nid" (the
217 FJC's), "title" and "sequence" (of the justice's appointment),
218 "martin_quinn" (the mean of the score's estimates), and "jcs"
219 (the score put on the scale of the Judicial Common Space).
220 "nid" and "sequence" are missing for a justice who is not found.
222 Raises:
223 RuntimeError: if there is no file of Martin-Quinn scores and it
224 cannot be downloaded.
226 """
227 files = _fjc_files(folder, update = update)
228 path = _martin_quinn_file(martin_quinn, scores_folder, update = update)
229 table = jcs.fjc.appointments(files['service'], files['demographics'])
230 terms = jcs.scores.justices(
231 jcs.politics.justices(path), table, start_year = 0)
232 terms = terms.drop(columns = ['jid'], errors = 'ignore')
233 return terms.astype({
234 'nid': 'Int64', 'sequence': 'Int64', 'martin_quinn': 'Float64',
235 'jcs': 'Float64'}).reset_index(drop = True)
238""" Private Functions """
241def _fjc_files(
242 folder: pathlib.Path | str | None,
243 *,
244 update: bool) -> dict[str, pathlib.Path]:
245 """Returns the FJC's files that the scores are computed from.
247 Args:
248 folder: the data folder, or None for `downloader.data_folder()`.
249 update: whether to download the files again even if they are there.
251 Returns:
252 The path of each file, by what judicial-common-space calls it
253 ("service", "demographics", and "career").
255 """
256 return downloader.download(
257 ['service', 'demographics', 'career'], folder, overwrite = update)
260def _insert(
261 roster: pd.DataFrame,
262 scores: pd.DataFrame,
263 columns: list[str]) -> pd.DataFrame:
264 """Returns a roster with scores of its appointments.
266 Args:
267 roster: the roster.
268 scores: a table with "nid", "sequence", and `columns`, with at most
269 one row for each appointment.
270 columns: the columns of `scores` to add.
272 Returns:
273 A new roster with `columns` after "senate_vote" (and any scores that
274 were added before), or at the end if there is no "senate_vote".
275 A column that the roster has already is replaced.
277 """
278 kept = roster.drop(columns = [c for c in columns if c in roster.columns])
279 keys = kept[['nid', 'sequence']].astype('Int64')
280 found = keys.merge(
281 scores[['nid', 'sequence', *columns]], on = ['nid', 'sequence'],
282 how = 'left', validate = 'many_to_one')
283 found.index = kept.index
284 order = list(kept.columns)
285 after = next(
286 (c for c in ('mq_terms', 'jcs', 'senate_vote') if c in order), None)
287 place = order.index(after) + 1 if after else len(order)
288 return pd.concat(
289 [kept.iloc[:, :place], found[columns], kept.iloc[:, place:]], axis = 1)
292def _martin_quinn_file(
293 path: pathlib.Path | str | None,
294 scores_folder: pathlib.Path | str | None,
295 *,
296 update: bool) -> pathlib.Path:
297 """Returns the file of the justices' Martin-Quinn scores.
299 Args:
300 path: the path of the file, or None to download it if it is not in
301 the data folder of judicial-common-space (or `scores_folder`).
302 scores_folder: the folder for the file, or None.
303 update: whether to download the file again even if it is there.
305 Returns:
306 The path of the file. If it cannot be downloaded again, an older
307 copy is used, with a warning.
309 Raises:
310 FileNotFoundError: if `path` names a file that does not exist.
311 RuntimeError: if there is no file and it cannot be downloaded.
313 """
314 if path is not None:
315 named = pathlib.Path(path).expanduser()
316 if not named.is_file():
317 message = f'there is no file of Martin-Quinn scores at {named}'
318 raise FileNotFoundError(message)
319 return named
320 saved: pathlib.Path = jcs.sources.paths(scores_folder)['martin_quinn']
321 try:
322 jcs.sources.download('martin_quinn', scores_folder, overwrite = update)
323 except Exception as error:
324 if not saved.is_file():
325 message = (
326 f'could not download the Martin-Quinn scores ({error}): save '
327 f'"justices.csv" from {jcs.options._MQ_PAGE} in a browser '
328 f'and pass its path as `martin_quinn` (or --martin-quinn)')
329 raise RuntimeError(message) from error
330 logger.warning(
331 'could not download the Martin-Quinn scores, so the copy at %s '
332 'is used: %s', saved, error)
333 return saved
336def _require(roster: pd.DataFrame, columns: list[str]) -> None:
337 """Raises an error if a roster lacks columns.
339 Args:
340 roster: the roster.
341 columns: names of the columns that are needed.
343 Raises:
344 KeyError: if a column is missing.
346 """
347 missing = [c for c in columns if c not in roster.columns]
348 if missing:
349 message = (
350 f'the roster needs the columns {missing} to add scores: make it '
351 f'with `build_roster`')
352 raise KeyError(message)