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

1"""Adds scores of judges' ideology to a roster. 

2 

3Two kinds of scores can be added to a roster made by `parser.build_roster`: 

4 

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. 

17 

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`. 

26 

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. 

33 

34""" 

35 

36from __future__ import annotations 

37 

38import logging 

39import pathlib 

40 

41import judicial_common_space as jcs 

42import pandas as pd 

43 

44from . import downloader 

45 

46logger = logging.getLogger(__name__) 

47 

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'} 

57 

58 

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". 

67 

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). 

74 

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. 

87 

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). 

92 

93 Raises: 

94 KeyError: if `roster` lacks "nid" or "sequence". 

95 

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']) 

120 

121 

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. 

131 

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: 

135 

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. | 

141 

142 They are missing for every other appointment, and for one with no 

143 scored terms. 

144 

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). 

161 

162 Returns: 

163 A new roster with the columns after "senate_vote" (and "jcs", if it 

164 is there). 

165 

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. 

170 

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)) 

186 

187 

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. 

195 

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`. 

200 

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. 

212 

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. 

221 

222 Raises: 

223 RuntimeError: if there is no file of Martin-Quinn scores and it 

224 cannot be downloaded. 

225 

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) 

236 

237 

238""" Private Functions """ 

239 

240 

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. 

246 

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. 

250 

251 Returns: 

252 The path of each file, by what judicial-common-space calls it 

253 ("service", "demographics", and "career"). 

254 

255 """ 

256 return downloader.download( 

257 ['service', 'demographics', 'career'], folder, overwrite = update) 

258 

259 

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. 

265 

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. 

271 

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. 

276 

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) 

290 

291 

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. 

298 

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. 

304 

305 Returns: 

306 The path of the file. If it cannot be downloaded again, an older 

307 copy is used, with a warning. 

308 

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. 

312 

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 

334 

335 

336def _require(roster: pd.DataFrame, columns: list[str]) -> None: 

337 """Raises an error if a roster lacks columns. 

338 

339 Args: 

340 roster: the roster. 

341 columns: names of the columns that are needed. 

342 

343 Raises: 

344 KeyError: if a column is missing. 

345 

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)