Coverage for src/crimepy/nibrs/munge.py: 86%

100 statements  

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

1"""Makes tables of NIBRS data ready for analysis in `pandas`. 

2 

3`prepare` is applied to every table that `crimepy.nibrs.read` and 

4`crimepy.nibrs.load` return. It keeps every column and adds: 

5 

6* Every table: `state`, the postal abbreviation of the state. 

7* Victims, offenders, and arrestees: `victim_age`, `offender_age`, and 

8 `arrestee_age`, the age in years (0 for a baby under a year old, 99 for 99 

9 or older, and missing if unknown). The age codes are kept as 

10 `victim_age_code`, and so on. 

11* Offenses, arrestees, and Group "B" arrests: `offense_category`, 

12 `crime_against`, and `offense_group` of the offense (with the prefix 

13 "arrest_" for arrests). 

14* Agencies: `population`, the population of all of the agency's counties, 

15 and `months_active`, the number of months in which the agency submitted a 

16 report (of incidents or of no incidents). 

17 

18With `decode = True` (the default), codes are replaced with their labels 

19(such as "Residence/Home" for location "20"), as categories. Codes that are 

20not in a codebook are kept as they are. `decode` turns the codes of a table 

21loaded with `decode = False` into labels later. 

22 

23Contents: 

24 CODE_COLUMNS: the codebook of each column of codes. 

25 add_ages: adds the age in years of each age code. 

26 add_offense_details: adds the category, target, and group of offenses. 

27 decode: replaces codes with their labels. 

28 prepare: makes a table ready for analysis. 

29 to_pandas: makes a `pyarrow` table into a `pandas` data frame. 

30 

31""" 

32 

33from __future__ import annotations 

34 

35from collections.abc import Callable, Mapping, Sequence 

36from typing import Any, TypeVar, cast 

37 

38import amos 

39import numpy as np 

40import pandas as pd 

41import pyarrow as pa 

42 

43from . import codes, layouts 

44 

45# The codebook of each column of codes, from the layouts. 

46CODE_COLUMNS: dict[str, str] = { 

47 f.name: f.codes 

48 for layout in layouts.LAYOUTS.values() for f in layout 

49 if f.kind == 'code' and f.codes is not None} 

50# A table, or an `amos.Dataset` of one. 

51_Table = TypeVar('_Table', pd.DataFrame, amos.Dataset) 

52# Codebooks whose codes are kept, because another column has their meaning 

53# (such as the age in years of each age code). 

54_KEPT_CODEBOOKS: frozenset[str] = frozenset({'ages'}) 

55# Text columns with few values, which are made categories. 

56_CATEGORY_TEXT: frozenset[str] = frozenset({ 

57 'state_abbreviation', 'city_submission', 'judicial_district', 

58 'field_office', 'reporting_indicator'}) 

59_MONTHS: tuple[str, ...] = ( 

60 'jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 

61 'nov', 'dec') 

62# The `pandas` type of each `pyarrow` type, so that whole numbers and 

63# booleans with missing values keep their types. 

64_PANDAS_TYPES: dict[pa.DataType, Any] = { 

65 pa.int64(): pd.Int64Dtype(), 

66 pa.bool_(): pd.BooleanDtype()} 

67 

68 

69""" Public Functions """ 

70 

71 

72def to_pandas(table: pa.Table) -> pd.DataFrame: 

73 """Returns a `pyarrow` table of NIBRS data as a `pandas` data frame. 

74 

75 Columns of codes become categories, whole numbers and booleans keep their 

76 types (with `pd.NA` for missing values), and dates become `datetime64`. 

77 

78 Args: 

79 table: a table made by `crimepy.nibrs.reader` or loaded from its 

80 parquet files. 

81 

82 Returns: 

83 The data frame. 

84 

85 """ 

86 columns = [] 

87 for name in table.column_names: 

88 column = table.column(name) 

89 if ((name in CODE_COLUMNS or name in _CATEGORY_TEXT) 

90 and pa.types.is_string(column.type)): 

91 column = column.dictionary_encode() 

92 columns.append(column) 

93 encoded = pa.table(columns, names = table.column_names) 

94 return cast('pd.DataFrame', encoded.to_pandas( 

95 types_mapper = _PANDAS_TYPES.get, date_as_object = False)) 

96 

97 

98def prepare( 

99 data: pd.DataFrame, 

100 table: str | None = None, 

101 *, 

102 decode: bool = True) -> pd.DataFrame: 

103 """Makes a table of NIBRS data ready for analysis. 

104 

105 Args: 

106 data: a table of NIBRS data (from `to_pandas`). 

107 table: the name of the table (such as "victims"). Defaults to `None`, 

108 in which case only the additions that every table gets are made. 

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

110 `True`. 

111 

112 Returns: 

113 The table, with the added columns (see the module's documentation). 

114 

115 """ 

116 if 'state_code' in data.columns and 'state' not in data.columns: 116 ↛ 121line 116 didn't jump to line 121 because the condition on line 116 was always true

117 state = _lookup(data['state_code'], codes.STATE_ABBREVIATIONS.get) 

118 data.insert( 

119 _after(data, 'state_code'), 'state', 

120 state.astype('category')) 

121 data = add_ages(data) 

122 data = add_offense_details(data) 

123 if table == 'agencies': 

124 data = _add_agency_totals(data) 

125 return _decode(data) if decode else data 

126 

127 

128def add_ages(data: pd.DataFrame) -> pd.DataFrame: 

129 """Adds the age in years of each column of age codes. 

130 

131 A column "victim_age_code" gets a column "victim_age" after it, and so 

132 on. Babies under a year old ("NN", "NB", and "BB") are 0, "99" (99 or 

133 older) is 99, and an unknown age ("00") is missing. 

134 

135 Args: 

136 data: a table with columns of age codes, whose names end in 

137 "_age_code". 

138 

139 Returns: 

140 The table, with the ages. 

141 

142 """ 

143 for column in [c for c in data.columns if str(c).endswith('_age_code')]: 

144 name = str(column).removesuffix('_code') 

145 if name in data.columns: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 continue 

147 values = _lookup(data[column], _age_years) 

148 data.insert( 

149 _after(data, column), name, pd.to_numeric(values).astype('Int64')) 

150 return data 

151 

152 

153def add_offense_details(data: pd.DataFrame) -> pd.DataFrame: 

154 """Adds the category, target, and group of each offense. 

155 

156 "offense_code" gets "offense_category", "crime_against", and 

157 "offense_group" after it, and "arrest_offense" gets the same columns with 

158 the prefix "arrest_". Only codes are looked up, so this is done before 

159 the codes are decoded. 

160 

161 Args: 

162 data: a table with an "offense_code" or "arrest_offense" column of 

163 codes. 

164 

165 Returns: 

166 The table, with the details. 

167 

168 """ 

169 for column, prefix in (('offense_code', ''), ('arrest_offense', 'arrest_')): 

170 if (column not in data.columns 

171 or f'{prefix}offense_category' in data.columns): 

172 continue 

173 offense = data[column] 

174 position = _after(data, column) 

175 for name, lookup in ( 

176 ('offense_category', codes.OFFENSE_CATEGORIES), 

177 ('crime_against', codes.CRIMES_AGAINST), 

178 ('offense_group', codes.OFFENSE_GROUPS)): 

179 data.insert( 

180 position, f'{prefix}{name}', 

181 _lookup(offense, lookup.get).astype('category')) 

182 position += 1 

183 return data 

184 

185 

186def decode( 

187 data: _Table, 

188 columns: Sequence[str] | None = None, 

189 codebooks: Mapping[str, Mapping[str, str]] | None = None) -> _Table: 

190 """Replaces codes with their labels. 

191 

192 Args: 

193 data: a table with columns of codes, or an `amos.Dataset` of one 

194 (whose data is decoded in place). 

195 columns: the columns to decode. Defaults to `None`, which decodes 

196 every column of codes in `CODE_COLUMNS` (except age codes, which 

197 `add_ages` turns into years). 

198 codebooks: codebooks to use instead of those in 

199 `crimepy.nibrs.codes.CODEBOOKS`, by column. Defaults to `None`. 

200 

201 Returns: 

202 The table (or dataset), with labels (as categories) instead of codes. 

203 Codes that are not in a codebook are kept. 

204 

205 """ 

206 if isinstance(data, amos.Dataset): 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 data.replace(decode(data.data, columns, codebooks)) 

208 return data 

209 codebooks = codebooks or {} 

210 if columns is None: 

211 columns = [ 

212 c for c in data.columns 

213 if c in CODE_COLUMNS and CODE_COLUMNS[c] not in _KEPT_CODEBOOKS] 

214 for column in columns: 

215 book = codebooks.get(column) or codes.CODEBOOKS[CODE_COLUMNS[column]] 

216 data[column] = _relabel(data[column], book) 

217 return data 

218 

219 

220""" Private Functions """ 

221 

222 

223# `prepare` has a `decode` argument, so it calls `decode` by this name. 

224_decode = decode 

225 

226 

227def _add_agency_totals(data: pd.DataFrame) -> pd.DataFrame: 

228 """Adds an agency's total population and number of active months. 

229 

230 Args: 

231 data: the table of agencies. 

232 

233 Returns: 

234 The table, with "population" and "months_active". 

235 

236 """ 

237 counties = [f'population_{n}' for n in range(1, 6)] 

238 if (all(c in data.columns for c in counties) 238 ↛ 244line 238 didn't jump to line 244 because the condition on line 238 was always true

239 and 'population' not in data.columns): 

240 position = _after(data, 'population_1') - 1 

241 data.insert( 

242 position, 'population', 

243 data[counties].sum(axis = 1, min_count = 1).astype('Int64')) 

244 flags = [ 

245 (f'zero_report_{m}', f'incident_report_{m}') for m in _MONTHS] 

246 if all(a in data.columns and b in data.columns for a, b in flags): 246 ↛ 252line 246 didn't jump to line 252 because the condition on line 246 was always true

247 active = sum( 

248 ((data[a].fillna(False) | data[b].fillna(False)).astype(int) 

249 for a, b in flags), 

250 start = pd.Series(0, index = data.index)) 

251 data['months_active'] = active.astype('Int64') 

252 return data 

253 

254 

255def _after(data: pd.DataFrame, column: str) -> int: 

256 """Returns the position just after `column` in `data`. 

257 

258 Args: 

259 data: a table. 

260 column: a column of `data`. 

261 

262 Returns: 

263 The position at which a column inserted after `column` goes. 

264 

265 """ 

266 return list(data.columns).index(column) + 1 

267 

268 

269def _age_years(code: Any) -> float | None: 

270 """Returns the age in years of an age code. 

271 

272 Args: 

273 code: an age code (such as "25" or "NB"), or a missing value. 

274 

275 Returns: 

276 The age in years, or `None` if it is unknown. 

277 

278 """ 

279 if not isinstance(code, str): 279 ↛ 280line 279 didn't jump to line 280 because the condition on line 279 was never true

280 return None 

281 if code in codes.AGE_YEARS: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 return codes.AGE_YEARS[code] 

283 return float(code) if code.isdigit() else None 

284 

285 

286def _lookup(values: pd.Series, find: Callable[[Any], Any]) -> pd.Series: 

287 """Returns what `find` returns for each value. 

288 

289 `find` is called once for each distinct value, so a column of categories 

290 with millions of rows is looked up quickly. 

291 

292 Args: 

293 values: the values to look up. 

294 find: returns the result for a value (or `None`). 

295 

296 Returns: 

297 The results, with the index of `values`. 

298 

299 """ 

300 if not isinstance(values.dtype, pd.CategoricalDtype): 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

301 values = values.astype('category') 

302 found = [find(c) for c in values.cat.categories] + [None] 

303 codes_ = values.cat.codes.to_numpy() 

304 return pd.Series( 

305 np.asarray(found, dtype = object)[codes_], index = values.index) 

306 

307 

308def _relabel(values: pd.Series, book: Mapping[str, str]) -> pd.Series: 

309 """Returns codes replaced with their labels, as categories. 

310 

311 Args: 

312 values: codes, as categories or text. 

313 book: the label of each code. 

314 

315 Returns: 

316 The labels, as categories in the order of `book`, followed by any 

317 codes that are not in it. 

318 

319 """ 

320 if not isinstance(values.dtype, pd.CategoricalDtype): 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 values = values.astype('category') 

322 present = [str(c) for c in values.cat.categories] 

323 labels = [book.get(c, c) for c in present] 

324 if len(set(labels)) != len(labels): 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

325 mapped = values.astype('object').map(lambda c: book.get(c, c)) 

326 return mapped.astype('category') 

327 relabeled = values.cat.rename_categories(labels) 

328 ordered = [v for v in book.values() if v in set(labels)] 

329 ordered += [v for v in labels if v not in set(ordered)] 

330 return relabeled.cat.reorder_categories(ordered)