crimepy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crimepy/__init__.py +25 -0
- crimepy/analyze.py +295 -0
- crimepy/cli.py +185 -0
- crimepy/combine.py +154 -0
- crimepy/nibrs/__init__.py +71 -0
- crimepy/nibrs/cde.py +345 -0
- crimepy/nibrs/codes.py +693 -0
- crimepy/nibrs/layouts.py +473 -0
- crimepy/nibrs/munge.py +305 -0
- crimepy/nibrs/reader.py +614 -0
- crimepy/nibrs/store.py +315 -0
- crimepy/nibrs/tables.py +494 -0
- crimepy/nibrs/techniques.py +180 -0
- crimepy/options.py +33 -0
- crimepy/plot.py +336 -0
- crimepy/techniques.py +542 -0
- crimepy/utilities.py +193 -0
- crimepy-0.1.0.dist-info/METADATA +318 -0
- crimepy-0.1.0.dist-info/RECORD +22 -0
- crimepy-0.1.0.dist-info/WHEEL +4 -0
- crimepy-0.1.0.dist-info/entry_points.txt +2 -0
- crimepy-0.1.0.dist-info/licenses/LICENSE +15 -0
crimepy/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Accessible tools for downloading, combining, munging, analyzing, and plotting crime data"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = '0.1.0'
|
|
6
|
+
|
|
7
|
+
__author__: str = 'Corey Rayburn Yung'
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = [
|
|
10
|
+
'Project',
|
|
11
|
+
'analyze',
|
|
12
|
+
'combine',
|
|
13
|
+
'nibrs',
|
|
14
|
+
'options',
|
|
15
|
+
'plot',
|
|
16
|
+
'techniques',
|
|
17
|
+
'utilities']
|
|
18
|
+
|
|
19
|
+
# A study is an `amos.Project`, which crimepy offers under the same name.
|
|
20
|
+
from amos import Project
|
|
21
|
+
|
|
22
|
+
# Importing `nibrs`, `plot`, and `techniques` adds their techniques to the
|
|
23
|
+
# `amos` library, so they can be named in settings as soon as crimepy is
|
|
24
|
+
# imported.
|
|
25
|
+
from . import analyze, combine, nibrs, options, plot, techniques, utilities
|
crimepy/analyze.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Analyses of crime data that researchers often need.
|
|
2
|
+
|
|
3
|
+
Each function applies one of the `amos` techniques of `crimepy.techniques`,
|
|
4
|
+
so the same analysis can be named in a project's settings. The functions
|
|
5
|
+
take an `amos.Dataset` or a data frame with one row for each event (an
|
|
6
|
+
incident, offense, victim, or arrest), such as those of `crimepy.nibrs.load`
|
|
7
|
+
and `crimepy.nibrs.tables.build`:
|
|
8
|
+
|
|
9
|
+
* `count`: the number of rows in each group and period, as a new dataset.
|
|
10
|
+
* `trend`: counts over time, one column for each group.
|
|
11
|
+
* `rates`: counts per 100,000 people (or another base).
|
|
12
|
+
* `crime_rates`: the rate of crime in each group, using the population of the
|
|
13
|
+
agencies that reported for the whole year.
|
|
14
|
+
* `clearance_rates`: the share of incidents cleared in each group.
|
|
15
|
+
* `percent_change`: the change from one period to the next.
|
|
16
|
+
* `crosstab`: counts (or shares) of two variables against each other.
|
|
17
|
+
* `coverage`: how many agencies reported, and how many people they serve.
|
|
18
|
+
|
|
19
|
+
Each function but `count` returns a table. Given a dataset, it also stores
|
|
20
|
+
the table in the dataset's `tables`, under the technique's name (such as
|
|
21
|
+
"clearance_rates").
|
|
22
|
+
|
|
23
|
+
Contents:
|
|
24
|
+
clearance_rates: the share of incidents cleared in each group.
|
|
25
|
+
count: the number of rows in each group and period.
|
|
26
|
+
coverage: the agencies that reported and the people they serve.
|
|
27
|
+
crime_rates: crimes per 100,000 people, by group.
|
|
28
|
+
crosstab: counts or shares of two variables against each other.
|
|
29
|
+
percent_change: the change from one period to the next.
|
|
30
|
+
rates: counts per 100,000 people.
|
|
31
|
+
trend: counts over time, one column for each group.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from typing import Any, Literal
|
|
39
|
+
|
|
40
|
+
import amos
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
from . import techniques, utilities
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def count(
|
|
47
|
+
data: amos.Dataset | pd.DataFrame,
|
|
48
|
+
by: Sequence[str] | str | None = None,
|
|
49
|
+
*,
|
|
50
|
+
date: str | None = None,
|
|
51
|
+
freq: str | None = None,
|
|
52
|
+
weight: str | None = None,
|
|
53
|
+
name: str = 'count') -> amos.Dataset:
|
|
54
|
+
"""Returns the number of rows in each group (and period).
|
|
55
|
+
|
|
56
|
+
Applies `crimepy.techniques.CountCrimes` to a copy of the data, so a
|
|
57
|
+
dataset that is passed keeps its rows.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
data: one row for each event.
|
|
61
|
+
by: columns to group by. Defaults to `None`, which counts all rows
|
|
62
|
+
(in each period, if `freq` is set).
|
|
63
|
+
date: column of dates that `freq` groups by. Defaults to `None`, in
|
|
64
|
+
which case it is "incident_date" if `freq` is set.
|
|
65
|
+
freq: length of each period, as a `pandas` frequency ("YS" for years,
|
|
66
|
+
"QS" for quarters, "MS" for months, "W" for weeks, "D" for days).
|
|
67
|
+
Defaults to `None`, which does not group by period.
|
|
68
|
+
weight: column whose values are summed instead of counting rows.
|
|
69
|
+
Defaults to `None`.
|
|
70
|
+
name: name of the column of counts. Defaults to "count".
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A dataset with a column for each group (and "period") and the counts,
|
|
74
|
+
which are its label. Groups with no rows are left out.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
return techniques.CountCrimes().apply(
|
|
78
|
+
utilities.to_dataset(utilities.to_frame(data)), label = name, by = by,
|
|
79
|
+
date = date, freq = freq, weight = weight, name = name)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def trend(
|
|
83
|
+
data: amos.Dataset | pd.DataFrame,
|
|
84
|
+
by: str | None = None,
|
|
85
|
+
*,
|
|
86
|
+
date: str = 'incident_date',
|
|
87
|
+
freq: str = 'MS',
|
|
88
|
+
top: int | None = None) -> pd.DataFrame:
|
|
89
|
+
"""Returns counts over time, with one column for each group.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
data: one row for each event.
|
|
93
|
+
by: column whose values become columns. Defaults to `None`, which
|
|
94
|
+
makes one column, "count".
|
|
95
|
+
date: column of dates. Defaults to "incident_date".
|
|
96
|
+
freq: length of each period, as a `pandas` frequency. Defaults to
|
|
97
|
+
"MS" (months).
|
|
98
|
+
top: keep only the groups with the most rows. Defaults to `None`,
|
|
99
|
+
which keeps them all.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A table indexed by the start of each period, with a column of counts
|
|
103
|
+
for each group (0 in periods with none).
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
return _describe(
|
|
107
|
+
techniques.CrimeTrend(), data, by = by, date = date, freq = freq,
|
|
108
|
+
top = top)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def rates(
|
|
112
|
+
counts: amos.Dataset | pd.DataFrame,
|
|
113
|
+
population: amos.Dataset | pd.DataFrame | str,
|
|
114
|
+
*,
|
|
115
|
+
on: Sequence[str] | str | None = None,
|
|
116
|
+
count: str = 'count',
|
|
117
|
+
per: float = 100_000,
|
|
118
|
+
name: str = 'rate') -> pd.DataFrame:
|
|
119
|
+
"""Returns counts per `per` people.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
counts: a table of counts (such as one from `count`).
|
|
123
|
+
population: a column of `counts` with the population, or a table of
|
|
124
|
+
populations to join to `counts` on `on`, with a "population"
|
|
125
|
+
column.
|
|
126
|
+
on: the columns that join `population` to `counts`. Defaults to
|
|
127
|
+
`None`, in which case they are the columns that both have (other
|
|
128
|
+
than "population").
|
|
129
|
+
count: the column of counts. Defaults to "count".
|
|
130
|
+
per: the number of people that rates are per. Defaults to 100,000.
|
|
131
|
+
name: name of the column of rates. Defaults to "rate".
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
`counts`, with the population (if it was joined) and the rates
|
|
135
|
+
(missing where the population is 0 or missing).
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
return _describe(
|
|
139
|
+
techniques.PopulationRates(), counts, population = population, on = on,
|
|
140
|
+
count = count, per = per, name = name)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def crime_rates(
|
|
144
|
+
events: amos.Dataset | pd.DataFrame,
|
|
145
|
+
agencies: amos.Dataset | pd.DataFrame | None = None,
|
|
146
|
+
by: Sequence[str] | str = ('data_year', 'state'),
|
|
147
|
+
*,
|
|
148
|
+
months: int = 12,
|
|
149
|
+
per: float = 100_000,
|
|
150
|
+
folder: Any = None) -> pd.DataFrame:
|
|
151
|
+
"""Returns the number of events per `per` people in each group.
|
|
152
|
+
|
|
153
|
+
Only agencies that reported in at least `months` months of the year (see
|
|
154
|
+
"months_active" in `crimepy.nibrs.munge`) count, both for the events and
|
|
155
|
+
for the population, so that the rate is not lowered by agencies that did
|
|
156
|
+
not report.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
events: one row for each event (such as an incident), with
|
|
160
|
+
"data_year" and "agency_ori" (or "ori").
|
|
161
|
+
agencies: the agencies, with "data_year", "ori", "population", and
|
|
162
|
+
"months_active". Defaults to `None`, in which case the agencies
|
|
163
|
+
of the years (and states) of `events` are loaded.
|
|
164
|
+
by: columns of the agencies to group by. Defaults to ("data_year",
|
|
165
|
+
"state").
|
|
166
|
+
months: the fewest months an agency must have reported in. Defaults
|
|
167
|
+
to 12.
|
|
168
|
+
per: the number of people that rates are per. Defaults to 100,000.
|
|
169
|
+
folder: the data folder to load agencies from. Defaults to `None`, in
|
|
170
|
+
which case it is `crimepy.options.DATA_FOLDER`.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A table with a column for each group, "count", "population",
|
|
174
|
+
"agencies", and "rate".
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
return _describe(
|
|
178
|
+
techniques.CrimeRates(), events, agencies = agencies, by = by,
|
|
179
|
+
months = months, per = per, folder = folder)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def clearance_rates(
|
|
183
|
+
incidents: amos.Dataset | pd.DataFrame,
|
|
184
|
+
by: Sequence[str] | str | None = None,
|
|
185
|
+
*,
|
|
186
|
+
cleared: str = 'cleared') -> pd.DataFrame:
|
|
187
|
+
"""Returns the share of incidents that were cleared in each group.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
incidents: one row for each incident, with a column saying whether
|
|
191
|
+
it was cleared (such as from `crimepy.nibrs.tables.incidents`).
|
|
192
|
+
by: columns to group by. Defaults to `None`, which uses all rows.
|
|
193
|
+
cleared: the column saying whether each incident was cleared.
|
|
194
|
+
Defaults to "cleared".
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A table with a column for each group, "incidents", "cleared", and
|
|
198
|
+
"clearance_rate" (from 0 to 1).
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
return _describe(
|
|
202
|
+
techniques.ClearanceRates(), incidents, by = by, cleared = cleared)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def percent_change(
|
|
206
|
+
data: amos.Dataset | pd.DataFrame | pd.Series,
|
|
207
|
+
periods: int = 1) -> pd.DataFrame | pd.Series:
|
|
208
|
+
"""Returns the percent change from one period to the next.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
data: counts (or rates) in each period, in order, such as a table
|
|
212
|
+
from `trend`.
|
|
213
|
+
periods: the number of periods to compare across (12 compares each
|
|
214
|
+
month with the same month of the year before). Defaults to 1.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The change, in percent, with the index of `data`.
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
if isinstance(data, pd.Series):
|
|
221
|
+
return data.pct_change(periods = periods, fill_method = None) * 100
|
|
222
|
+
return _describe(techniques.PercentChange(), data, periods = periods)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def crosstab(
|
|
226
|
+
data: amos.Dataset | pd.DataFrame,
|
|
227
|
+
rows: str,
|
|
228
|
+
columns: str,
|
|
229
|
+
*,
|
|
230
|
+
normalize: bool | Literal['all', 'index', 'columns'] = False,
|
|
231
|
+
margins: bool = False) -> pd.DataFrame:
|
|
232
|
+
"""Returns counts (or shares) of two variables against each other.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
data: one row for each event.
|
|
236
|
+
rows: the column whose values become rows.
|
|
237
|
+
columns: the column whose values become columns.
|
|
238
|
+
normalize: False for counts, True for shares of all rows, "index"
|
|
239
|
+
for shares of each row, or "columns" for shares of each column.
|
|
240
|
+
Defaults to `False`.
|
|
241
|
+
margins: whether to add totals. Defaults to `False`.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The table.
|
|
245
|
+
|
|
246
|
+
"""
|
|
247
|
+
return _describe(
|
|
248
|
+
techniques.Crosstab(), data, rows = rows, columns = columns,
|
|
249
|
+
normalize = normalize, margins = margins)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def coverage(
|
|
253
|
+
agencies: amos.Dataset | pd.DataFrame,
|
|
254
|
+
by: Sequence[str] | str = ('data_year', 'state'),
|
|
255
|
+
*,
|
|
256
|
+
months: int = 12) -> pd.DataFrame:
|
|
257
|
+
"""Returns how many agencies reported and how many people they serve.
|
|
258
|
+
|
|
259
|
+
Agencies covered by another agency (whose crimes the other agency
|
|
260
|
+
reports) are left out, so that people are not counted twice.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
agencies: the agencies, with "population" and "months_active".
|
|
264
|
+
by: columns to group by. Defaults to ("data_year", "state").
|
|
265
|
+
months: the months that an agency must have reported in to count as
|
|
266
|
+
reporting for the year. Defaults to 12.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
A table with a column for each group, "agencies", "reporting" (the
|
|
270
|
+
agencies that reported in at least `months` months),
|
|
271
|
+
"population", "population_reporting", and "share_reporting" (the
|
|
272
|
+
share of the population served by agencies that reported).
|
|
273
|
+
|
|
274
|
+
"""
|
|
275
|
+
return _describe(techniques.Coverage(), agencies, by = by, months = months)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _describe(
|
|
279
|
+
technique: amos.Describer,
|
|
280
|
+
data: amos.Dataset | pd.DataFrame,
|
|
281
|
+
**kwargs: Any) -> pd.DataFrame:
|
|
282
|
+
"""Applies a describer to `data` and returns its table.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
technique: the describer.
|
|
286
|
+
data: a dataset (which keeps the table in its `tables`) or a data
|
|
287
|
+
frame.
|
|
288
|
+
**kwargs: parameters for the describer.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The table.
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
dataset = technique.apply(utilities.to_dataset(data), **kwargs)
|
|
295
|
+
return dataset.tables[technique.name]
|
crimepy/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The command line interface of crimepy.
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
crimepy nibrs years
|
|
5
|
+
crimepy nibrs download 2021-2023
|
|
6
|
+
crimepy nibrs convert 2021-2023
|
|
7
|
+
crimepy nibrs export victims 2022 --states DE --output victims.csv
|
|
8
|
+
crimepy project study.ini --export
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`export` writes a table of the master files (such as "agencies") or a built
|
|
12
|
+
table (such as "incidents"; see `crimepy.nibrs.tables`) to a csv or parquet
|
|
13
|
+
file, downloading and converting the years first if needed. `project` runs an
|
|
14
|
+
`amos` project from a settings file.
|
|
15
|
+
|
|
16
|
+
Contents:
|
|
17
|
+
main: runs the command line interface.
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import pathlib
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
|
|
28
|
+
from . import nibrs, options
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(arguments: Sequence[str] | None = None) -> int:
|
|
32
|
+
"""Runs the command line interface.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
arguments: the arguments, without the program's name. Defaults to
|
|
36
|
+
`None`, in which case they are those that the program was called
|
|
37
|
+
with.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The exit status: 0 if the command succeeded.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
parser = _parser()
|
|
44
|
+
parsed = parser.parse_args(arguments)
|
|
45
|
+
if not hasattr(parsed, 'run'):
|
|
46
|
+
parser.print_help()
|
|
47
|
+
return 1
|
|
48
|
+
return int(parsed.run(parsed) or 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parser() -> argparse.ArgumentParser:
|
|
52
|
+
"""Returns the parser of the command line arguments."""
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog = 'crimepy',
|
|
55
|
+
description = (
|
|
56
|
+
'Download, combine, munge, analyze, and plot crime data.'))
|
|
57
|
+
commands = parser.add_subparsers(title = 'commands')
|
|
58
|
+
data = commands.add_parser('nibrs', help = 'work with NIBRS master files')
|
|
59
|
+
tasks = data.add_subparsers(title = 'NIBRS commands')
|
|
60
|
+
|
|
61
|
+
years = tasks.add_parser('years', help = 'list the years the FBI has')
|
|
62
|
+
years.set_defaults(run = _years)
|
|
63
|
+
|
|
64
|
+
download = tasks.add_parser('download', help = 'download master files')
|
|
65
|
+
_add_years(download)
|
|
66
|
+
_add_folder(download)
|
|
67
|
+
download.add_argument(
|
|
68
|
+
'--overwrite', action = 'store_true',
|
|
69
|
+
help = 'download files that were downloaded before')
|
|
70
|
+
download.set_defaults(run = _download)
|
|
71
|
+
|
|
72
|
+
convert = tasks.add_parser(
|
|
73
|
+
'convert', help = 'convert master files to parquet files')
|
|
74
|
+
_add_years(convert)
|
|
75
|
+
_add_folder(convert)
|
|
76
|
+
convert.add_argument(
|
|
77
|
+
'--tables', nargs = '+', default = None,
|
|
78
|
+
help = 'the tables to convert (all of them by default)')
|
|
79
|
+
convert.add_argument(
|
|
80
|
+
'--overwrite', action = 'store_true',
|
|
81
|
+
help = 'convert tables that were converted before')
|
|
82
|
+
convert.set_defaults(run = _convert)
|
|
83
|
+
|
|
84
|
+
export = tasks.add_parser(
|
|
85
|
+
'export', help = 'write a table to a csv or parquet file')
|
|
86
|
+
export.add_argument(
|
|
87
|
+
'table',
|
|
88
|
+
choices = sorted({*nibrs.layouts.SEGMENTS, *nibrs.tables.BUILDERS}),
|
|
89
|
+
help = 'the table to write')
|
|
90
|
+
_add_years(export)
|
|
91
|
+
_add_folder(export)
|
|
92
|
+
export.add_argument(
|
|
93
|
+
'--states', nargs = '+', default = None,
|
|
94
|
+
help = 'postal abbreviations of the states to keep')
|
|
95
|
+
export.add_argument(
|
|
96
|
+
'--codes', action = 'store_true',
|
|
97
|
+
help = 'keep codes instead of replacing them with labels')
|
|
98
|
+
export.add_argument(
|
|
99
|
+
'--output', required = True, type = pathlib.Path,
|
|
100
|
+
help = 'the file to write (.csv or .parquet)')
|
|
101
|
+
export.set_defaults(run = _export)
|
|
102
|
+
|
|
103
|
+
project = commands.add_parser(
|
|
104
|
+
'project', help = 'run an amos project from a settings file')
|
|
105
|
+
project.add_argument('settings', type = pathlib.Path)
|
|
106
|
+
project.add_argument(
|
|
107
|
+
'--export', action = 'store_true',
|
|
108
|
+
help = 'export the results to a folder named for the run')
|
|
109
|
+
project.set_defaults(run = _project)
|
|
110
|
+
return parser
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _add_folder(parser: argparse.ArgumentParser) -> None:
|
|
114
|
+
"""Adds the "--folder" argument to `parser`."""
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
'--folder', type = pathlib.Path, default = None,
|
|
117
|
+
help = f'the data folder (by default, {options.DATA_FOLDER})')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _add_years(parser: argparse.ArgumentParser) -> None:
|
|
121
|
+
"""Adds the "years" argument to `parser`."""
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
'years', nargs = '+',
|
|
124
|
+
help = 'years or ranges of years, such as 2022 or 2015-2023')
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _convert(arguments: argparse.Namespace) -> int:
|
|
128
|
+
"""Converts master files to parquet files."""
|
|
129
|
+
converted = nibrs.convert(
|
|
130
|
+
arguments.years, arguments.folder, tables = arguments.tables,
|
|
131
|
+
overwrite = arguments.overwrite)
|
|
132
|
+
for year, paths in converted.items():
|
|
133
|
+
folder = next(iter(paths.values())).parent
|
|
134
|
+
print(f'{year}: {len(paths)} tables in {folder}')
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _download(arguments: argparse.Namespace) -> int:
|
|
139
|
+
"""Downloads master files."""
|
|
140
|
+
for path in nibrs.download(
|
|
141
|
+
arguments.years, arguments.folder, overwrite = arguments.overwrite):
|
|
142
|
+
print(path)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _export(arguments: argparse.Namespace) -> int:
|
|
147
|
+
"""Writes a table to a csv or parquet file."""
|
|
148
|
+
suffix = arguments.output.suffix.lower()
|
|
149
|
+
if suffix not in ('.csv', '.parquet'):
|
|
150
|
+
print('the output must be a .csv or .parquet file', file = sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
decode = not arguments.codes
|
|
153
|
+
if arguments.table in nibrs.tables.BUILDERS:
|
|
154
|
+
data = nibrs.tables.build(
|
|
155
|
+
arguments.table, arguments.years, states = arguments.states,
|
|
156
|
+
folder = arguments.folder, decode = decode).data
|
|
157
|
+
else:
|
|
158
|
+
data = nibrs.load_frame(
|
|
159
|
+
arguments.table, arguments.years, states = arguments.states,
|
|
160
|
+
folder = arguments.folder, decode = decode)
|
|
161
|
+
arguments.output.parent.mkdir(parents = True, exist_ok = True)
|
|
162
|
+
if suffix == '.csv':
|
|
163
|
+
data.to_csv(arguments.output, index = False)
|
|
164
|
+
else:
|
|
165
|
+
data.to_parquet(arguments.output, index = False)
|
|
166
|
+
print(f'wrote {len(data):,} rows to {arguments.output}')
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _project(arguments: argparse.Namespace) -> int:
|
|
171
|
+
"""Runs an amos project from a settings file."""
|
|
172
|
+
import amos # noqa: PLC0415
|
|
173
|
+
|
|
174
|
+
project = amos.Project.create(arguments.settings)
|
|
175
|
+
if arguments.export:
|
|
176
|
+
print(project.export())
|
|
177
|
+
print(project.result)
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _years(arguments: argparse.Namespace) -> int: # noqa: ARG001
|
|
182
|
+
"""Lists the years of NIBRS master files that the FBI has."""
|
|
183
|
+
found = nibrs.available_years()
|
|
184
|
+
print(f'{found[0]}-{found[-1]}')
|
|
185
|
+
return 0
|
crimepy/combine.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Combines tables of crime data.
|
|
2
|
+
|
|
3
|
+
`pandas.concat` turns a column of categories into plain objects when the
|
|
4
|
+
tables have different categories (as tables of different years usually do),
|
|
5
|
+
and `pandas.merge` does the same to the keys it joins on. These functions
|
|
6
|
+
keep the categories, so combined tables stay small and keep their labels.
|
|
7
|
+
|
|
8
|
+
They take `amos` datasets or data frames and return an `amos.Dataset`, which
|
|
9
|
+
keeps the label, task, and groups of the first dataset given when its
|
|
10
|
+
columns are still there.
|
|
11
|
+
|
|
12
|
+
Contents:
|
|
13
|
+
merge: joins two tables, keeping their categories.
|
|
14
|
+
stack: stacks tables on top of each other, keeping their categories.
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import amos
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from pandas.api.types import union_categoricals
|
|
26
|
+
|
|
27
|
+
from . import utilities
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stack(
|
|
31
|
+
tables: Iterable[amos.Dataset | pd.DataFrame],
|
|
32
|
+
*,
|
|
33
|
+
source: str | None = None,
|
|
34
|
+
labels: Sequence[Any] | None = None) -> amos.Dataset:
|
|
35
|
+
"""Stacks tables on top of each other, keeping their categories.
|
|
36
|
+
|
|
37
|
+
Columns that are in some tables but not others are missing (`NA`) in the
|
|
38
|
+
rows of the others. A column of categories in every table that has it
|
|
39
|
+
stays a column of categories, with all of their categories.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
tables: the tables to stack (such as one table for each year).
|
|
43
|
+
source: name of a column to add that says which table each row came
|
|
44
|
+
from. Defaults to `None`, which adds no column.
|
|
45
|
+
labels: what `source` says for each table. Defaults to `None`, in
|
|
46
|
+
which case the tables are numbered from 0.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: if there are no tables, or `labels` does not have one
|
|
50
|
+
label for each table.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A dataset of the stacked table, with a new index.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
items = list(tables)
|
|
57
|
+
frames = [utilities.to_frame(t) for t in items]
|
|
58
|
+
if not frames:
|
|
59
|
+
message = 'there are no tables to stack'
|
|
60
|
+
raise ValueError(message)
|
|
61
|
+
if labels is not None and len(labels) != len(frames):
|
|
62
|
+
message = 'labels must have one label for each table'
|
|
63
|
+
raise ValueError(message)
|
|
64
|
+
if source is not None:
|
|
65
|
+
names = list(labels) if labels is not None else list(range(len(frames)))
|
|
66
|
+
frames = [
|
|
67
|
+
f.assign(**{source: name}) for f, name in zip(frames, names, strict = True)]
|
|
68
|
+
columns = list(dict.fromkeys(c for f in frames for c in f.columns))
|
|
69
|
+
categorical = [
|
|
70
|
+
c for c in columns
|
|
71
|
+
if all(
|
|
72
|
+
isinstance(f[c].dtype, pd.CategoricalDtype)
|
|
73
|
+
for f in frames if c in f.columns)]
|
|
74
|
+
for column in categorical:
|
|
75
|
+
union = union_categoricals(
|
|
76
|
+
[f[column].array for f in frames if column in f.columns],
|
|
77
|
+
ignore_order = True)
|
|
78
|
+
frames = [
|
|
79
|
+
f.assign(**{column: f[column].cat.set_categories(union.categories)})
|
|
80
|
+
if column in f.columns else f
|
|
81
|
+
for f in frames]
|
|
82
|
+
stacked = pd.concat(frames, ignore_index = True)
|
|
83
|
+
for column in categorical:
|
|
84
|
+
if not isinstance(stacked[column].dtype, pd.CategoricalDtype):
|
|
85
|
+
stacked[column] = stacked[column].astype('category')
|
|
86
|
+
return _dataset(stacked, items)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def merge(
|
|
90
|
+
left: amos.Dataset | pd.DataFrame,
|
|
91
|
+
right: amos.Dataset | pd.DataFrame,
|
|
92
|
+
on: Sequence[str] | str,
|
|
93
|
+
*,
|
|
94
|
+
how: str = 'left',
|
|
95
|
+
suffixes: tuple[str, str] = ('', '_right'),
|
|
96
|
+
validate: str | None = None) -> amos.Dataset:
|
|
97
|
+
"""Joins two tables, keeping the categories of their columns.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
left: the first table.
|
|
101
|
+
right: the second table.
|
|
102
|
+
on: the columns to join on, which both tables have.
|
|
103
|
+
how: "left", "right", "inner", or "outer". Defaults to "left".
|
|
104
|
+
suffixes: added to the names of columns (other than `on`) that both
|
|
105
|
+
tables have: the first to those of `left`, the second to those of
|
|
106
|
+
`right`. Defaults to ('', '_right').
|
|
107
|
+
validate: checks the kind of join, as in `pandas.merge` (such as
|
|
108
|
+
"many_to_one"). Defaults to `None`, which checks nothing.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A dataset of the joined table.
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
keys = [on] if isinstance(on, str) else list(on)
|
|
115
|
+
restore = {}
|
|
116
|
+
items = [left, right]
|
|
117
|
+
left, right = utilities.to_frame(left).copy(), utilities.to_frame(right).copy()
|
|
118
|
+
for key in keys:
|
|
119
|
+
kinds = (left[key].dtype, right[key].dtype)
|
|
120
|
+
if any(isinstance(k, pd.CategoricalDtype) for k in kinds):
|
|
121
|
+
restore[key] = True
|
|
122
|
+
left[key] = left[key].astype('object')
|
|
123
|
+
right[key] = right[key].astype('object')
|
|
124
|
+
joined = left.merge(
|
|
125
|
+
right, on = keys, how = how, suffixes = suffixes, # type: ignore[arg-type]
|
|
126
|
+
validate = validate) # type: ignore[arg-type]
|
|
127
|
+
for key in restore:
|
|
128
|
+
joined[key] = joined[key].astype('category')
|
|
129
|
+
return _dataset(joined, items)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _dataset(
|
|
133
|
+
data: pd.DataFrame,
|
|
134
|
+
items: Sequence[amos.Dataset | pd.DataFrame]) -> amos.Dataset:
|
|
135
|
+
"""Returns `data` as a dataset like the first dataset of `items`.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
data: the combined table.
|
|
139
|
+
items: the datasets and data frames that were combined.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A dataset with the label, task, and groups of the first dataset in
|
|
143
|
+
`items` whose label and groups are columns of `data` (and with
|
|
144
|
+
none if no dataset was given).
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
first = next((i for i in items if isinstance(i, amos.Dataset)), None)
|
|
148
|
+
if first is None:
|
|
149
|
+
return amos.Dataset.create(data)
|
|
150
|
+
label = first.label if first.label in data.columns else None
|
|
151
|
+
groups = [g for g in first.groups if g in data.columns]
|
|
152
|
+
return amos.Dataset.create(
|
|
153
|
+
data, label = label, task = first.task if label else None,
|
|
154
|
+
seed = first.seed, groups = groups or None)
|