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

82 statements  

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

1"""Converts NIBRS master files to parquet files and loads tables from them. 

2 

3A master file is read and parsed once by `convert`, which saves each table 

4of the year as a parquet file in the "nibrs/tables/{year}" folder of the data 

5folder. `load` then reads any table of any years from those files, keeping 

6only the states and columns asked for, which takes seconds instead of the 

7minutes that parsing a master file takes. `load` downloads and converts the 

8years that are missing, so it is usually the only function needed. It 

9returns an `amos.Dataset`, whose `data` is the table: 

10 

11```python 

12import crimepy 

13 

14victims = crimepy.nibrs.load('victims', years = '2021-2023', states = 'DE') 

15victims.data.head() 

16``` 

17 

18Contents: 

19 convert: converts the master files of years to parquet files. 

20 load: loads a table of years into an `amos.Dataset`. 

21 load_frame: loads a table of years into a `pandas` data frame. 

22 load_tables: loads several tables of years. 

23 table_path: returns where a table of a year is saved. 

24 

25""" 

26 

27from __future__ import annotations 

28 

29import logging 

30import pathlib 

31import sys 

32from collections.abc import Sequence 

33from typing import Any 

34 

35import amos 

36import pandas as pd 

37import pyarrow.dataset as ds 

38import pyarrow.parquet as pq 

39 

40from .. import utilities 

41from . import cde, munge, reader 

42 

43logger = logging.getLogger(__name__) 

44 

45 

46""" Public Functions """ 

47 

48 

49def table_path( 

50 table: str, 

51 year: int, 

52 folder: pathlib.Path | str | None = None) -> pathlib.Path: 

53 """Returns where a table of a year is saved. 

54 

55 Args: 

56 table: the name of the table (such as "victims"). 

57 year: the year. 

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

59 `crimepy.utilities.data_folder()`. 

60 

61 Returns: 

62 The path of the parquet file, which may not exist yet. 

63 

64 """ 

65 tables = utilities.data_folder(folder) / 'nibrs' / 'tables' 

66 return tables / str(int(year)) / f'{table}.parquet' 

67 

68 

69def convert( 

70 years: Any, 

71 folder: pathlib.Path | str | None = None, 

72 *, 

73 tables: Sequence[str] | str | None = None, 

74 overwrite: bool = False, 

75 progress: bool = True, 

76 chunk_size: int | None = None) -> dict[int, dict[str, pathlib.Path]]: 

77 """Converts the NIBRS master files of years to parquet files. 

78 

79 Master files that have not been downloaded are downloaded first. Tables 

80 that were converted before are skipped unless `overwrite` is true, and a 

81 year whose tables were all converted before is not read again. 

82 

83 Args: 

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

85 `crimepy.utilities.to_years`). 

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

87 `crimepy.utilities.data_folder()`. 

88 tables: the tables to convert. Defaults to `None`, which converts 

89 them all. 

90 overwrite: whether to convert tables that were converted before (and 

91 to download their master files again). Defaults to `False`. 

92 progress: whether to show progress. Defaults to `True`. 

93 chunk_size: the number of records to parse at once. Defaults to 

94 `None`, in which case it is `crimepy.options.CHUNK_SIZE`. 

95 

96 Returns: 

97 For each year, the path of each table's parquet file. 

98 

99 """ 

100 names = reader._wanted(tables) 

101 converted = {} 

102 for year in utilities.to_years(years): 

103 paths = {n: table_path(n, year, folder) for n in names} 

104 missing = [n for n, p in paths.items() if overwrite or not p.is_file()] 

105 if missing: 

106 master = cde.master_path(year, folder) 

107 if overwrite or not master.is_file(): 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 cde.download( 

109 year, folder, overwrite = overwrite, progress = progress) 

110 _write(master, year, missing, paths, progress, chunk_size) 

111 converted[year] = paths 

112 return converted 

113 

114 

115def load( 

116 table: str, 

117 years: Any, 

118 *, 

119 folder: pathlib.Path | str | None = None, 

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

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

122 decode: bool = True, 

123 fetch: bool = True, 

124 progress: bool = True, 

125 label: str | None = None, 

126 task: str | None = None, 

127 groups: Sequence[str] | str | None = None) -> amos.Dataset: 

128 """Loads a table of NIBRS data for years into an `amos.Dataset`. 

129 

130 The years are stacked into one table, with "data_year" saying which 

131 master file each row came from. The table is the dataset's `data`. 

132 

133 Args: 

134 table: the name of the table (see `load_frame`). 

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

136 `crimepy.utilities.to_years`). 

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

138 `crimepy.utilities.data_folder()`. 

139 states: postal abbreviations (such as "DE") or numeric state codes 

140 of the states to keep. Defaults to `None`, which keeps every 

141 state. 

142 columns: the columns to load. Defaults to `None`, which loads them 

143 all. 

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

145 `True`. 

146 fetch: whether to download and convert years that have not been 

147 converted. Defaults to `True`. 

148 progress: whether to show the progress of downloads and conversions. 

149 Defaults to `True`. 

150 label: the column that models predict. Defaults to `None`. 

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

152 `amos` infers it from the label. 

153 groups: columns that identify groups of rows (such as "ori"). 

154 Defaults to `None`. 

155 

156 Returns: 

157 The dataset. 

158 

159 """ 

160 frame = load_frame( 

161 table, years, folder = folder, states = states, columns = columns, 

162 decode = decode, fetch = fetch, progress = progress) 

163 return amos.Dataset.create( 

164 frame, label = label, task = task, 

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

166 

167 

168def load_frame( 

169 table: str, 

170 years: Any, 

171 *, 

172 folder: pathlib.Path | str | None = None, 

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

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

175 decode: bool = True, 

176 fetch: bool = True, 

177 progress: bool = True) -> pd.DataFrame: 

178 """Loads a table of NIBRS data for years into a `pandas` data frame. 

179 

180 The years are stacked into one table, with "data_year" saying which 

181 master file each row came from. `load` returns the same table as an 

182 `amos.Dataset`. 

183 

184 Args: 

185 table: the name of the table: "administrative", "offenses", 

186 "properties", "victims", "offenders", "arrestees", 

187 "group_b_arrests", "window_clearances", "window_properties", 

188 "window_arrestees", or "agencies". 

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

190 `crimepy.utilities.to_years`). 

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

192 `crimepy.utilities.data_folder()`. 

193 states: postal abbreviations (such as "DE") or numeric state codes 

194 of the states to keep. Defaults to `None`, which keeps every 

195 state. 

196 columns: the columns to load. Defaults to `None`, which loads them 

197 all. Columns that `crimepy.nibrs.munge.prepare` adds (such as 

198 "state") are added if the columns they come from are loaded. 

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

200 `True`. 

201 fetch: whether to download and convert years that have not been 

202 converted. Defaults to `True`. 

203 progress: whether to show the progress of downloads and conversions. 

204 Defaults to `True`. 

205 

206 Raises: 

207 FileNotFoundError: if a year has not been converted and `fetch` is 

208 false. 

209 

210 Returns: 

211 The table. 

212 

213 """ 

214 schema = reader.schema(table) 

215 paths = [] 

216 for year in utilities.to_years(years): 

217 path = table_path(table, year, folder) 

218 if not path.is_file(): 

219 if not fetch: 

220 message = ( 

221 f'the {table} of {year} have not been converted: use ' 

222 f'crimepy.nibrs.convert({year}) or fetch = True') 

223 raise FileNotFoundError(message) 

224 convert(year, folder, progress = progress) 

225 paths.append(path) 

226 if not paths: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 message = 'there are no years to load' 

228 raise ValueError(message) 

229 if columns is not None: 

230 unknown = [c for c in columns if c not in schema.names] 

231 if unknown: 

232 message = f'{table!r} has no columns {unknown}' 

233 raise KeyError(message) 

234 dataset = ds.dataset( 

235 [str(p) for p in paths], schema = schema, format = 'parquet') 

236 kept = reader._state_codes(states) 

237 condition = ( 

238 ds.field('state_code').isin([c.decode() for c in kept]) 

239 if kept else None) 

240 loaded = dataset.to_table( 

241 columns = list(columns) if columns is not None else None, 

242 filter = condition) 

243 return munge.prepare(munge.to_pandas(loaded), table, decode = decode) 

244 

245 

246def load_tables( 

247 tables: Sequence[str] | str | None, 

248 years: Any, 

249 **kwargs: Any) -> dict[str, amos.Dataset]: 

250 """Loads several tables of NIBRS data for years. 

251 

252 Args: 

253 tables: the names of the tables (see `load_frame`). `None` loads them 

254 all. 

255 years: a year, years, or a range of years. 

256 **kwargs: arguments for `load` (except "columns"). 

257 

258 Returns: 

259 A dataset of each table, by name. 

260 

261 """ 

262 return { 

263 name: load(name, years, **kwargs) 

264 for name in reader._wanted(tables)} 

265 

266 

267""" Private Functions """ 

268 

269 

270def _write( 

271 master: pathlib.Path, 

272 year: int, 

273 names: list[str], 

274 paths: dict[str, pathlib.Path], 

275 progress: bool, 

276 chunk_size: int | None) -> None: 

277 """Parses a master file and saves its tables as parquet files. 

278 

279 Each table is written to a temporary file, which replaces the table's 

280 file only when the whole master file has been read, so an interrupted 

281 conversion leaves nothing that looks finished. 

282 

283 Args: 

284 master: path of the master file. 

285 year: the year of the master file. 

286 names: the tables to save. 

287 paths: where to save each table. 

288 progress: whether to show how many records have been read. 

289 chunk_size: the number of records to parse at once. 

290 

291 """ 

292 partial = {n: paths[n].with_name(f'{paths[n].name}.part') for n in names} 

293 writers: dict[str, pq.ParquetWriter] = {} 

294 

295 def show(count: int) -> None: 

296 """Shows how many records have been read. 

297 

298 Args: 

299 count: the number of records read so far. 

300 

301 """ 

302 print( 

303 f'\rnibrs-{year}: {count:,} records read', end = '', 

304 file = sys.stderr, flush = True) 

305 

306 try: 

307 for chunk in reader.tables( 

308 master, names, year = year, chunk_size = chunk_size, 

309 progress = show if progress else None): 

310 for name, table in chunk.items(): 

311 if name not in writers: 311 ↛ 316line 311 didn't jump to line 316 because the condition on line 311 was always true

312 partial[name].parent.mkdir(parents = True, exist_ok = True) 

313 writers[name] = pq.ParquetWriter( 

314 partial[name], reader.schema(name), 

315 compression = 'zstd') 

316 writers[name].write_table(table) 

317 finally: 

318 for writer in writers.values(): 

319 writer.close() 

320 if progress: 

321 print(file = sys.stderr) 

322 for name in names: 

323 if name not in writers: 

324 partial[name].parent.mkdir(parents = True, exist_ok = True) 

325 pq.write_table(reader.schema(name).empty_table(), partial[name]) 

326 partial[name].replace(paths[name]) 

327 logger.info(f'saved {paths[name]}')