Coverage for src/crimepy/nibrs/tables.py: 95%

138 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 18:01 +0000

1"""Joins the tables of NIBRS incidents into flat tables for analysis. 

2 

3The master files spread each incident over several tables (its offenses, 

4victims, offenders, and arrestees), and an analysis usually needs them 

5together. Each builder here returns one flat table with one row for each 

6incident, victim, offense, offender, or arrestee, and the details of its 

7incident and agency: 

8 

9* `incidents`: one row for each incident, with its agency; the number of its 

10 offenses, victims, offenders, and arrestees; whether it had an offense 

11 against a person, property, or society, and an offense of each category; 

12 whether a firearm was used; whether and when someone was arrested; and 

13 whether it was cleared. 

14* `victims`: one row for each victim, with its incident and agency, and the 

15 age, sex, race, and ethnicity of the first offender that the victim is 

16 related to, with the relationship. 

17* `offenses`: one row for each offense, with its incident and agency. 

18* `offenders`: one row for each offender, with its incident and agency. 

19* `arrestees`: one row for each arrestee, with its incident and agency. 

20 

21`build` loads what a builder needs and calls it. Each builder takes the 

22segments as `amos` datasets or data frames and returns an `amos.Dataset`, 

23whose `data` is the built table (and which can be given a `label`, `task`, 

24and `groups` for modeling): 

25 

26```python 

27import crimepy 

28 

29victims = crimepy.nibrs.tables.build('victims', 2022, states = 'DE') 

30victims.data.head() 

31``` 

32 

33The tables of an incident share the columns in `KEYS`. Agencies are joined 

34by the ORI of the batch header that each record follows ("agency_ori"), and 

35their columns get the prefix "agency_" (such as "agency_population"). 

36 

37Contents: 

38 AGENCY_COLUMNS: the columns of agencies that are joined to incidents. 

39 BUILDERS: the builders, by name. 

40 KEYS: the columns that identify an incident. 

41 arrestees: one row for each arrestee. 

42 build: loads the tables that a builder needs and builds its table. 

43 incidents: one row for each incident. 

44 offenders: one row for each offender. 

45 offenses: one row for each offense. 

46 victims: one row for each victim. 

47 

48""" 

49 

50from __future__ import annotations 

51 

52import functools 

53import re 

54from collections.abc import Callable, Mapping, Sequence 

55from typing import Any 

56 

57import amos 

58import numpy as np 

59import pandas as pd 

60 

61from .. import utilities 

62from . import codes, store 

63 

64KEYS: list[str] = ['data_year', 'ori', 'incident_number'] 

65# Columns of agencies that are joined to incidents, with their new names. 

66AGENCY_COLUMNS: dict[str, str] = { 

67 'city': 'agency_city', 

68 'agency_type': 'agency_type', 

69 'population': 'agency_population', 

70 'population_group': 'agency_population_group', 

71 'core_city': 'agency_core_city', 

72 'division': 'agency_division', 

73 'region': 'agency_region', 

74 'fips_county_1': 'agency_fips_county', 

75 'msa_1': 'agency_msa', 

76 'months_active': 'agency_months_active'} 

77# Weapon codes of firearms, and their labels. 

78_FIREARMS: frozenset[str] = frozenset( 

79 {'11', '12', '13', '14', '15'} 

80 | {codes.CODEBOOKS['weapons'][c] for c in ('11', '12', '13', '14', '15')}) 

81# Codes and labels of incidents that were not cleared exceptionally. 

82_NOT_CLEARED: frozenset[str] = frozenset({'N', 'Not Applicable'}) 

83# Columns of the administrative segment that are joined to other tables. 

84_INCIDENT_COLUMNS: tuple[str, ...] = ( 

85 'incident_hour', 'report_date', 'cleared_exceptionally', 

86 'exceptional_clearance_date', 'cargo_theft') 

87 

88 

89""" Decorators """ 

90 

91 

92def _dataset_builder( 

93 builder: Callable[..., pd.DataFrame]) -> Callable[..., amos.Dataset]: 

94 """Makes a builder take datasets or data frames and return a dataset. 

95 

96 The builder is written for data frames. The returned function accepts 

97 each segment as an `amos.Dataset` or a data frame, and the keyword 

98 arguments "label", "task", and "groups" of `amos.Dataset.create`. 

99 

100 Args: 

101 builder: a function that builds a data frame from data frames. 

102 

103 Returns: 

104 The function, which returns an `amos.Dataset`. 

105 

106 """ 

107 

108 @functools.wraps(builder) 

109 def build_dataset( 

110 segments: Mapping[str, Any], 

111 *args: Any, 

112 label: str | None = None, 

113 task: str | None = None, 

114 groups: Sequence[str] | str | None = None, 

115 **kwargs: Any) -> amos.Dataset: 

116 """Builds the table and returns it as a dataset. 

117 

118 Args: 

119 segments: the tables of the incidents (as datasets or data 

120 frames), by name. Tables that are `None` are left out. 

121 *args: arguments for the builder. 

122 label: the label of the dataset. Defaults to `None`. 

123 task: "classify" or "regress". Defaults to `None`. 

124 groups: the groups of the dataset. Defaults to `None`. 

125 **kwargs: arguments for the builder. 

126 

127 Returns: 

128 A dataset whose `data` is the built table. 

129 

130 """ 

131 frames = { 

132 name: utilities.to_frame(table) for name, table in segments.items() 

133 if table is not None} 

134 return amos.Dataset.create( 

135 builder(frames, *args, **kwargs), label = label, task = task, 

136 groups = utilities.to_names(groups) or None) 

137 

138 return build_dataset 

139 

140 

141""" Builders """ 

142 

143 

144@_dataset_builder 

145def incidents( 

146 segments: Mapping[str, pd.DataFrame], 

147 *, 

148 categories: bool = True) -> pd.DataFrame: 

149 """Returns one row for each incident. 

150 

151 Args: 

152 segments: the tables of the incidents (as datasets or data 

153 frames), by name. "administrative" is 

154 needed. "offenses", "victims", "offenders", "arrestees", and 

155 "agencies" are used if they are there. 

156 categories: whether to add a column for each category of offense 

157 (such as "has_assault_offenses"), saying whether the incident had 

158 an offense of that category. Defaults to `True`. 

159 

160 Raises: 

161 KeyError: if there is no "administrative" table. 

162 

163 Returns: 

164 The incidents (once each, though a few are in the master files more 

165 than once), with the columns of the administrative segment and: 

166 "incident_weekday" and "incident_month"; the agency's columns 

167 (see `AGENCY_COLUMNS`); "offense_count", "victim_count", 

168 "offender_count" (offenders whose sequence number is not 0, 

169 which means unknown), and "arrestee_count"; "against_person", 

170 "against_property", and "against_society"; "firearm"; 

171 "arrested", "first_arrest_date", and "days_to_arrest"; and 

172 "cleared" (by an arrest or exceptionally). 

173 

174 """ 

175 # A few incidents are in the master files more than once (193 of the 12 

176 # million in 2023), so only the first record of each is kept. 

177 data = _require(segments, 'administrative').drop_duplicates(KEYS) 

178 dates = pd.to_datetime(data['incident_date']) 

179 data['incident_weekday'] = pd.Categorical( 

180 dates.dt.day_name(), 

181 categories = [ 

182 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 

183 'Saturday', 'Sunday']) 

184 data['incident_month'] = dates.dt.month.astype('Int64') 

185 data = _join_agencies(data, segments.get('agencies')) 

186 offense_table = segments.get('offenses') 

187 if offense_table is not None: 187 ↛ 189line 187 didn't jump to line 189 because the condition on line 187 was always true

188 data = _join_offense_summary(data, offense_table, categories) 

189 counted = (('victims', 'victim_count'), ('offenders', 'offender_count')) 

190 for name, column in counted: 

191 table = segments.get(name) 

192 if table is not None: 192 ↛ 190line 192 didn't jump to line 190 because the condition on line 192 was always true

193 if name == 'offenders': 

194 table = table[table['offender_sequence'].fillna(0) > 0] 

195 data = _join_counts(data, table, column) 

196 arrests = segments.get('arrestees') 

197 if arrests is not None: 197 ↛ 207line 197 didn't jump to line 207 because the condition on line 197 was always true

198 data = _join_counts(data, arrests, 'arrestee_count') 

199 first = arrests.groupby(KEYS, observed = True)['arrest_date'].min() 

200 data = data.merge( 

201 first.rename('first_arrest_date').reset_index(), on = KEYS, 

202 how = 'left') 

203 data['arrested'] = data['arrestee_count'] > 0 

204 data['days_to_arrest'] = ( 

205 data['first_arrest_date'] - pd.to_datetime(data['incident_date']) 

206 ).dt.days.astype('Int64') 

207 exceptional = data['cleared_exceptionally'].astype('object') 

208 cleared = exceptional.notna() & ~exceptional.isin(_NOT_CLEARED) 

209 if 'arrested' in data.columns: 209 ↛ 211line 209 didn't jump to line 211 because the condition on line 209 was always true

210 cleared = cleared | data['arrested'] 

211 data['cleared'] = cleared.astype('boolean') 

212 return data 

213 

214 

215@_dataset_builder 

216def victims(segments: Mapping[str, pd.DataFrame]) -> pd.DataFrame: 

217 """Returns one row for each victim. 

218 

219 Args: 

220 segments: the tables of the incidents (as datasets or data 

221 frames), by name. "victims" is needed. 

222 "administrative", "offenders", and "agencies" are used if they 

223 are there. 

224 

225 Raises: 

226 KeyError: if there is no "victims" table. 

227 

228 Returns: 

229 The victims, with the incident's columns (see `_INCIDENT_COLUMNS`), 

230 the agency's columns, and, from the first offender that the 

231 victim is related to: "offender_sequence", "relationship", 

232 "offender_age", "offender_sex", "offender_race", and 

233 "offender_ethnicity". 

234 

235 """ 

236 data = _with_incident(_require(segments, 'victims'), segments) 

237 offenders_ = segments.get('offenders') 

238 if offenders_ is not None: 238 ↛ 251line 238 didn't jump to line 251 because the condition on line 238 was always true

239 data['offender_sequence'] = data['offender_1'].astype('Int64') 

240 data['relationship'] = data['relationship_1'] 

241 columns = [ 

242 c for c in ( 

243 'offender_age', 'offender_sex', 'offender_race', 

244 'offender_ethnicity') 

245 if c in offenders_.columns] 

246 people = offenders_[[*KEYS, 'offender_sequence', *columns]] 

247 people = people.drop_duplicates([*KEYS, 'offender_sequence']) 

248 people = people.astype({'offender_sequence': 'Int64'}) 

249 data = data.merge( 

250 people, on = [*KEYS, 'offender_sequence'], how = 'left') 

251 return data 

252 

253 

254@_dataset_builder 

255def offenses(segments: Mapping[str, pd.DataFrame]) -> pd.DataFrame: 

256 """Returns one row for each offense. 

257 

258 Args: 

259 segments: the tables of the incidents (as datasets or data 

260 frames), by name. "offenses" is needed. 

261 "administrative" and "agencies" are used if they are there. 

262 

263 Raises: 

264 KeyError: if there is no "offenses" table. 

265 

266 Returns: 

267 The offenses, with the incident's and agency's columns. 

268 

269 """ 

270 return _with_incident(_require(segments, 'offenses'), segments) 

271 

272 

273@_dataset_builder 

274def offenders(segments: Mapping[str, pd.DataFrame]) -> pd.DataFrame: 

275 """Returns one row for each offender. 

276 

277 Args: 

278 segments: the tables of the incidents (as datasets or data 

279 frames), by name. "offenders" is 

280 needed. "administrative" and "agencies" are used if they are 

281 there. 

282 

283 Raises: 

284 KeyError: if there is no "offenders" table. 

285 

286 Returns: 

287 The offenders, with the incident's and agency's columns. 

288 

289 """ 

290 return _with_incident(_require(segments, 'offenders'), segments) 

291 

292 

293@_dataset_builder 

294def arrestees(segments: Mapping[str, pd.DataFrame]) -> pd.DataFrame: 

295 """Returns one row for each arrestee of a Group "A" incident. 

296 

297 Args: 

298 segments: the tables of the incidents (as datasets or data 

299 frames), by name. "arrestees" is 

300 needed. "administrative" and "agencies" are used if they are 

301 there. 

302 

303 Raises: 

304 KeyError: if there is no "arrestees" table. 

305 

306 Returns: 

307 The arrestees, with the incident's and agency's columns, and 

308 "days_to_arrest". 

309 

310 """ 

311 data = _with_incident(_require(segments, 'arrestees'), segments) 

312 data['days_to_arrest'] = ( 

313 pd.to_datetime(data['arrest_date']) 

314 - pd.to_datetime(data['incident_date'])).dt.days.astype('Int64') 

315 return data 

316 

317 

318BUILDERS: dict[str, Callable[..., amos.Dataset]] = { 

319 'arrestees': arrestees, 

320 'incidents': incidents, 

321 'offenders': offenders, 

322 'offenses': offenses, 

323 'victims': victims} 

324# The tables that each builder uses. 

325_NEEDS: dict[str, tuple[str, ...]] = { 

326 'arrestees': ('arrestees', 'administrative', 'agencies'), 

327 'incidents': ( 

328 'administrative', 'offenses', 'victims', 'offenders', 'arrestees', 

329 'agencies'), 

330 'offenders': ('offenders', 'administrative', 'agencies'), 

331 'offenses': ('offenses', 'administrative', 'agencies'), 

332 'victims': ('victims', 'administrative', 'offenders', 'agencies')} 

333 

334 

335""" Public Functions """ 

336 

337 

338def build( 

339 table: str, 

340 years: Any, 

341 *, 

342 states: Sequence[str] | str | None = None, 

343 folder: Any = None, 

344 decode: bool = True, 

345 label: str | None = None, 

346 task: str | None = None, 

347 groups: Sequence[str] | str | None = None, 

348 **kwargs: Any) -> amos.Dataset: 

349 """Loads the tables that a builder needs and builds its table. 

350 

351 Args: 

352 table: the name of a builder in `BUILDERS` ("incidents", "victims", 

353 "offenses", "offenders", or "arrestees"). 

354 years: a year, years, or a range of years (see 

355 `crimepy.utilities.to_years`). 

356 states: postal abbreviations or numeric state codes of the states to 

357 keep. Defaults to `None`, which keeps every state. 

358 folder: the data folder. Defaults to `None`, in which case it is 

359 `crimepy.utilities.data_folder()`. 

360 decode: whether to replace codes with their labels. Defaults to 

361 `True`. 

362 label: the column that models predict (such as "cleared"). 

363 Defaults to `None`. 

364 task: "classify" or "regress". Defaults to `None`, in which case 

365 `amos` infers it from the label. 

366 groups: columns that identify groups of rows (such as 

367 "agency_ori"). Defaults to `None`. 

368 **kwargs: arguments for the builder (such as `categories` for 

369 `incidents`). 

370 

371 Raises: 

372 KeyError: if `table` is not a builder. 

373 

374 Returns: 

375 A dataset whose `data` is the built table. 

376 

377 """ 

378 if table not in BUILDERS: 

379 message = ( 

380 f'{table!r} is not a table to build: use one of ' 

381 f'{sorted(BUILDERS)}') 

382 raise KeyError(message) 

383 segments = { 

384 name: store.load_frame( 

385 name, years, states = states, folder = folder, decode = decode) 

386 for name in _NEEDS[table]} 

387 return BUILDERS[table]( 

388 segments, label = label, task = task, groups = groups, **kwargs) 

389 

390 

391""" Private Functions """ 

392 

393 

394def _join_agencies( 

395 data: pd.DataFrame, 

396 agencies: pd.DataFrame | None) -> pd.DataFrame: 

397 """Joins the columns of agencies to records by their batch header's ORI. 

398 

399 Args: 

400 data: records with "data_year" and "agency_ori" (or "ori"). 

401 agencies: the table of agencies, or `None`. 

402 

403 Returns: 

404 `data`, with the agency's columns (see `AGENCY_COLUMNS`). 

405 

406 """ 

407 if agencies is None: 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 return data 

409 present = {k: v for k, v in AGENCY_COLUMNS.items() if k in agencies.columns} 

410 table = agencies[['data_year', 'ori', *present]].rename( 

411 columns = {'ori': '_agency', **present}) 

412 table = table.drop_duplicates(['data_year', '_agency']) 

413 key = 'agency_ori' if 'agency_ori' in data.columns else 'ori' 

414 existing = [c for c in present.values() if c in data.columns] 

415 joined = data.drop(columns = existing).merge( 

416 table, left_on = ['data_year', key], 

417 right_on = ['data_year', '_agency'], how = 'left') 

418 return joined.drop(columns = '_agency') 

419 

420 

421def _join_counts( 

422 data: pd.DataFrame, 

423 table: pd.DataFrame, 

424 column: str) -> pd.DataFrame: 

425 """Joins the number of rows of `table` for each incident. 

426 

427 Args: 

428 data: one row for each incident. 

429 table: rows of incidents (such as their victims). 

430 column: name of the column of counts. 

431 

432 Returns: 

433 `data`, with the counts (0 for incidents with no rows). 

434 

435 """ 

436 counts = table.groupby(KEYS, observed = True).size().rename(column) 

437 joined = data.merge(counts.reset_index(), on = KEYS, how = 'left') 

438 joined[column] = joined[column].fillna(0).astype('Int64') 

439 return joined 

440 

441 

442def _join_offense_summary( 

443 data: pd.DataFrame, 

444 table: pd.DataFrame, 

445 categories: bool) -> pd.DataFrame: 

446 """Joins what the offenses of each incident were. 

447 

448 Args: 

449 data: one row for each incident. 

450 table: the offenses. 

451 categories: whether to add a column for each category of offense. 

452 

453 Returns: 

454 `data`, with "offense_count", "against_person", "against_property", 

455 "against_society", "firearm", and (if `categories`) a column for 

456 each category of offense. 

457 

458 """ 

459 data = _join_counts(data, table, 'offense_count') 

460 flags = pd.DataFrame(table[KEYS]) 

461 against = table['crime_against'].astype('object') 

462 for target in ('Person', 'Property', 'Society'): 

463 flags[f'against_{target.lower()}'] = (against == target).to_numpy() 

464 weapons = [c for c in table.columns if re.fullmatch(r'weapon_\d', str(c))] 

465 firearm = np.zeros(len(table), dtype = bool) 

466 for column in weapons: 

467 firearm |= table[column].astype('object').isin(_FIREARMS).to_numpy() 

468 flags['firearm'] = firearm 

469 if categories and 'offense_category' in table.columns: 469 ↛ 473line 469 didn't jump to line 473 because the condition on line 469 was always true

470 category = table['offense_category'].astype('object') 

471 for name in sorted(category.dropna().unique()): 

472 flags[f'has_{_snake(name)}'] = (category == name).to_numpy() 

473 summary = flags.groupby(KEYS, observed = True).any() 

474 joined = data.merge(summary.reset_index(), on = KEYS, how = 'left') 

475 for column in summary.columns: 

476 joined[column] = joined[column].astype('boolean').fillna(False) 

477 return joined 

478 

479 

480def _require(segments: Mapping[str, pd.DataFrame], name: str) -> pd.DataFrame: 

481 """Returns the table `name` of `segments`. 

482 

483 Args: 

484 segments: tables, by name. 

485 name: the name of the table that is needed. 

486 

487 Raises: 

488 KeyError: if there is no such table. 

489 

490 Returns: 

491 The table. 

492 

493 """ 

494 if name not in segments or segments[name] is None: 

495 message = f'the {name!r} table is needed' 

496 raise KeyError(message) 

497 return segments[name] 

498 

499 

500def _snake(text: str) -> str: 

501 """Returns `text` in snake case. 

502 

503 Args: 

504 text: a name, such as "Assault Offenses". 

505 

506 Returns: 

507 The name in lower case with underscores between its words, such as 

508 "assault_offenses". 

509 

510 """ 

511 words = re.sub(r'[^0-9a-zA-Z]+', '_', text).strip('_') 

512 return words.lower() 

513 

514 

515def _with_incident( 

516 data: pd.DataFrame, 

517 segments: Mapping[str, pd.DataFrame]) -> pd.DataFrame: 

518 """Returns records with the columns of their incident and agency. 

519 

520 Args: 

521 data: records of incidents (such as their victims). 

522 segments: the tables of the incidents, by name. "administrative" and 

523 "agencies" are used if they are there. 

524 

525 Returns: 

526 The records, with the incident's columns (see `_INCIDENT_COLUMNS`) 

527 and the agency's columns. 

528 

529 """ 

530 data = data.copy() 

531 administrative = segments.get('administrative') 

532 if administrative is not None: 532 ↛ 538line 532 didn't jump to line 538 because the condition on line 532 was always true

533 columns = [ 

534 c for c in _INCIDENT_COLUMNS 

535 if c in administrative.columns and c not in data.columns] 

536 incident = administrative[[*KEYS, *columns]].drop_duplicates(KEYS) 

537 data = data.merge(incident, on = KEYS, how = 'left') 

538 return _join_agencies(data, segments.get('agencies'))