Coverage for src/crimepy/techniques.py: 96%
135 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 18:01 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-30 18:01 +0000
1"""Techniques that analyze crime data, on `amos` datasets.
3Each technique is added to the `amos` library when crimepy is imported, so
4it can be named in the settings of any `amos` worker, and each can be applied
5to an `amos.Dataset` (or a data frame) directly. A describer stores its table
6in the dataset's `tables` under its name, and a shaper replaces the
7dataset's rows:
9* `count_crimes` (an `amos.Shaper`) turns one row for each event into one
10 row for each group (and period), with the number of events, so that
11 counts can be modeled (for example, with `amos`'s `poisson` or
12 `negative_binomial` models).
13* `crime_trend` (an `amos.Describer`) makes a table of counts over time.
14* `population_rates` (an `amos.Describer`) makes counts per 100,000 people.
15* `crime_rates` (an `amos.Describer`) makes the rate of crime in each group,
16 using only agencies that reported all year.
17* `clearance_rates` (an `amos.Describer`) makes a table of the share of
18 incidents cleared in each group.
19* `percent_change` (an `amos.Describer`) makes the change from one period to
20 the next.
21* `crosstab` (an `amos.Describer`) makes counts (or shares) of two variables
22 against each other.
23* `coverage` (an `amos.Describer`) makes a table of the agencies that
24 reported and the people they serve, from a dataset of agencies.
26The functions of `crimepy.analyze` apply these techniques. The figures of
27`crimepy.plot` (`trend_plot`, `top_values`, `time_heatmap`, and
28`age_pyramid`) are techniques too.
30Contents:
31 ClearanceRates: the share of incidents cleared in each group.
32 CountCrimes: counts events by group and period.
33 Coverage: the agencies that reported and the people they serve.
34 CrimeRates: crimes per 100,000 people, by group.
35 CrimeTrend: counts of events over time.
36 Crosstab: counts or shares of two variables against each other.
37 PercentChange: the change from one period to the next.
38 PopulationRates: counts per 100,000 people.
40"""
42from __future__ import annotations
44import dataclasses
45from typing import Any, Literal
47import amos
48import pandas as pd
50from . import utilities
52""" Techniques """
55@dataclasses.dataclass
56class CountCrimes(amos.Shaper):
57 """Counts events by group (and period).
59 Parameters (set in the "count_crimes_parameters" section of the
60 settings):
62 * `by`: the columns to group by, such as "state, offense_category".
63 * `date`: the column of dates. Defaults to "incident_date" if `freq` is
64 set.
65 * `freq`: the length of each period ("YS", "QS", "MS", "W", or "D").
66 Defaults to none, which does not group by period.
67 * `weight`: a column whose values are summed instead of counting rows.
68 Defaults to none.
69 * `name`: the name of the column of counts. Defaults to "count".
70 * `label`: the label of the counted data. Defaults to the dataset's
71 label, or, if it has none, the column of counts.
73 """
75 """ Required Methods """
77 def shape(
78 self,
79 data: pd.DataFrame,
80 by: Any = None,
81 date: str | None = None,
82 freq: str | None = None,
83 weight: str | None = None,
84 name: str = 'count',
85 **kwargs: Any) -> pd.DataFrame:
86 """Returns the number of events in each group (and period).
88 Args:
89 data: one row for each event.
90 by: the columns to group by. Defaults to `None`, which counts all
91 rows (in each period, if `freq` is set).
92 date: the column of dates that `freq` groups by. Defaults to
93 `None`, in which case it is "incident_date" if `freq` is set.
94 freq: the length of each period, as a `pandas` frequency ("YS"
95 for years, "QS" for quarters, "MS" for months, "W" for weeks,
96 "D" for days). Defaults to `None`, which does not group by
97 period.
98 weight: a column whose values are summed instead of counting
99 rows. Defaults to `None`.
100 name: the name of the column of counts. Defaults to "count".
101 **kwargs: not used.
103 Returns:
104 A table with a column for each group (and "period"), and the
105 counts. Groups with no rows are left out.
107 """
108 keys = utilities.to_names(by)
109 freq = freq or None
110 groupers: list[Any] = list(keys)
111 if freq is not None:
112 date = date or 'incident_date'
113 groupers.append(pd.Grouper(key = date, freq = freq))
114 if not groupers:
115 total = data[weight].sum() if weight else len(data)
116 return pd.DataFrame({name: [total]})
117 grouped = data.groupby(groupers, observed = True, dropna = False)
118 counts = grouped[weight].sum() if weight else grouped.size()
119 table = counts.rename(name).reset_index()
120 if freq is not None:
121 table = table.rename(columns = {date: 'period'})
122 return table[table[name] > 0].reset_index(drop = True)
124 """ Public Methods """
126 def apply(
127 self,
128 item: amos.Dataset,
129 *,
130 label: str | None = None,
131 task: str | None = None,
132 groups: Any = None,
133 **kwargs: Any) -> amos.Dataset:
134 """Counts the events of `item`.
136 Args:
137 item: the dataset, with one row for each event.
138 label: the label of the counted data. Defaults to `None`, which
139 keeps the label of `item` or, if it has none, makes the
140 column of counts the label.
141 task: "classify" or "regress". Defaults to `None`.
142 groups: the groups of the counted data. Defaults to `None`, which
143 keeps the groups of `item`.
144 **kwargs: the parameters of `shape`.
146 Returns:
147 The counted dataset.
149 """
150 if label is None and item.label is None:
151 label = kwargs.get('name', 'count')
152 return super().apply(
153 item, label = label, task = task, groups = groups, **kwargs)
156@dataclasses.dataclass
157class CrimeTrend(amos.Describer):
158 """A table of counts of events over time, with a column for each group.
160 Parameters (set in the "crime_trend_parameters" section of the
161 settings): `by`, `date`, `freq`, and `top` (see `describe`).
163 """
165 """ Required Methods """
167 def describe(
168 self,
169 item: amos.Dataset,
170 by: str | None = None,
171 date: str = 'incident_date',
172 freq: str = 'MS',
173 top: Any = None,
174 **kwargs: Any) -> pd.DataFrame:
175 """Returns counts over time.
177 Args:
178 item: the dataset, with one row for each event.
179 by: the column whose values become columns. Defaults to `None`,
180 which makes one column, "count".
181 date: the column of dates. Defaults to "incident_date".
182 freq: the length of each period, as a `pandas` frequency.
183 Defaults to "MS" (months).
184 top: keep only the groups with the most rows. Defaults to `None`,
185 which keeps them all.
186 **kwargs: not used.
188 Returns:
189 A table indexed by the start of each period, with a column of
190 counts for each group (0 in periods with none).
192 """
193 counted = CountCrimes().shape(item.data, by, date = date, freq = freq)
194 if by is None:
195 return counted.set_index('period')
196 wide = counted.pivot_table(
197 index = 'period', columns = by, values = 'count', aggfunc = 'sum',
198 fill_value = 0, observed = True)
199 if top is not None:
200 largest = wide.sum().nlargest(int(top)).index
201 wide = wide[largest]
202 wide.columns = [str(c) for c in wide.columns]
203 return wide
206@dataclasses.dataclass
207class PopulationRates(amos.Describer):
208 """Counts per 100,000 people (or another base), from a table of counts.
210 Parameters (set in the "population_rates_parameters" section of the
211 settings): `population`, `on`, `count`, `per`, and `name` (see
212 `describe`).
214 """
216 """ Required Methods """
218 def describe(
219 self,
220 item: amos.Dataset,
221 population: Any = 'population',
222 on: Any = None,
223 count: str = 'count',
224 per: float = 100_000,
225 name: str = 'rate',
226 **kwargs: Any) -> pd.DataFrame:
227 """Returns counts per `per` people.
229 Args:
230 item: a dataset of counts (such as one from `count_crimes`).
231 population: a column of the counts with the population, or a
232 table (a dataset or a data frame) of populations to join to
233 the counts on `on`, with a "population" column. Defaults to
234 "population".
235 on: the columns that join `population` to the counts. Defaults to
236 `None`, in which case they are the columns that both have
237 (other than "population").
238 count: the column of counts. Defaults to "count".
239 per: the number of people that rates are per. Defaults to
240 100,000.
241 name: name of the column of rates. Defaults to "rate".
242 **kwargs: not used.
244 Returns:
245 The counts, with the population (if it was joined) and the rates
246 (missing where the population is 0 or missing).
248 """
249 table = item.data.copy()
250 column = 'population'
251 if isinstance(population, str):
252 column = population
253 else:
254 people = utilities.to_frame(population)
255 keys = utilities.to_names(on) or [
256 c for c in people.columns
257 if c in table.columns and c != 'population']
258 table = table.merge(people, on = keys, how = 'left')
259 values = pd.to_numeric(
260 table[column], errors = 'coerce').astype('Float64')
261 values = values.where(values > 0)
262 table[name] = table[count] / values * float(per)
263 return table
266@dataclasses.dataclass
267class CrimeRates(amos.Describer):
268 """Crimes per 100,000 people in each group, from agencies that reported.
270 Only agencies that reported in at least `months` months of the year (see
271 "months_active" in `crimepy.nibrs.munge`) count, both for the events and
272 for the population, so that the rate is not lowered by agencies that did
273 not report.
275 Parameters (set in the "crime_rates_parameters" section of the settings):
276 `by`, `months`, `per`, and `folder` (see `describe`). In settings, the
277 agencies are loaded for the years and states of the data.
279 """
281 """ Required Methods """
283 def describe(
284 self,
285 item: amos.Dataset,
286 agencies: Any = None,
287 by: Any = ('data_year', 'state'),
288 months: Any = 12,
289 per: float = 100_000,
290 folder: Any = None,
291 **kwargs: Any) -> pd.DataFrame:
292 """Returns the number of events per `per` people in each group.
294 Args:
295 item: a dataset with one row for each event (such as an
296 incident), with "data_year" and "agency_ori" (or "ori").
297 agencies: the agencies (a dataset or a data frame), with
298 "data_year", "ori", "population", and "months_active".
299 Defaults to `None`, in which case the agencies of the years
300 (and states, if the data has a "state" column) of the data
301 are loaded.
302 by: columns of the agencies to group by. Defaults to
303 ("data_year", "state").
304 months: the fewest months an agency must have reported in.
305 Defaults to 12.
306 per: the number of people that rates are per. Defaults to
307 100,000.
308 folder: the data folder to load agencies from. Defaults to
309 `None`, in which case it is `crimepy.utilities.data_folder()`.
310 **kwargs: not used.
312 Returns:
313 A table with a column for each group, "count", "population",
314 "agencies", and "rate".
316 """
317 events = item.data
318 if agencies is None:
319 agencies = _agencies_of(events, folder)
320 agencies = utilities.to_frame(agencies)
321 keys = utilities.to_names(by)
322 active = agencies[agencies['months_active'].fillna(0) >= int(months)]
323 active = active.drop_duplicates(['data_year', 'ori'])
324 key = 'agency_ori' if 'agency_ori' in events.columns else 'ori'
325 columns = [k for k in keys if k not in ('data_year',)]
326 joined = [c for c in columns if c in active.columns]
327 kept = events[['data_year', key]].merge(
328 active[['data_year', 'ori', *joined]],
329 left_on = ['data_year', key], right_on = ['data_year', 'ori'],
330 how = 'inner', suffixes = ('_event', ''))
331 counts = kept.groupby(keys, observed = True).size().rename('count')
332 people = active.groupby(keys, observed = True).agg(
333 population = ('population', 'sum'), agencies = ('ori', 'nunique'))
334 table = people.join(counts, how = 'left').reset_index()
335 table['count'] = table['count'].fillna(0).astype('Int64')
336 table = PopulationRates().describe(
337 amos.Dataset(table), 'population', per = per)
338 return table[[*keys, 'count', 'population', 'agencies', 'rate']]
341@dataclasses.dataclass
342class ClearanceRates(amos.Describer):
343 """A table of the share of incidents cleared in each group.
345 Parameters (set in the "clearance_rates_parameters" section of the
346 settings): `by` (the columns to group by) and `cleared` (the column
347 saying whether each incident was cleared, "cleared" by default).
349 """
351 """ Required Methods """
353 def describe(
354 self,
355 item: amos.Dataset,
356 by: Any = None,
357 cleared: str = 'cleared',
358 **kwargs: Any) -> pd.DataFrame:
359 """Returns the share of incidents that were cleared in each group.
361 Args:
362 item: the dataset, with one row for each incident and a column
363 saying whether it was cleared (such as from
364 `crimepy.nibrs.tables.incidents`).
365 by: the columns to group by. Defaults to `None`, which uses all
366 rows.
367 cleared: the column saying whether each incident was cleared.
368 Defaults to "cleared".
369 **kwargs: not used.
371 Returns:
372 A table with a column for each group, "incidents", "cleared", and
373 "clearance_rate" (from 0 to 1).
375 """
376 incidents = item.data
377 keys = utilities.to_names(by)
378 flags = incidents[cleared].astype('boolean').fillna(False).astype(int)
379 if keys:
380 grouped = flags.groupby(
381 [incidents[k] for k in keys], observed = True, dropna = False)
382 table = grouped.agg(['size', 'sum']).reset_index()
383 else:
384 table = pd.DataFrame({'size': [len(flags)], 'sum': [flags.sum()]})
385 table = table.rename(columns = {'size': 'incidents', 'sum': 'cleared'})
386 table['clearance_rate'] = table['cleared'] / table['incidents']
387 return table
390@dataclasses.dataclass
391class PercentChange(amos.Describer):
392 """The percent change from one period to the next.
394 Applied to a dataset of events, it counts them over time first (as
395 `crime_trend` does, if `freq` is set). Applied to a table of counts or
396 rates in each period (in order), it changes those.
398 Parameters (set in the "percent_change_parameters" section of the
399 settings): `periods`, `by`, `date`, and `freq` (see `describe`).
401 """
403 """ Required Methods """
405 def describe(
406 self,
407 item: amos.Dataset,
408 periods: Any = 1,
409 by: str | None = None,
410 date: str = 'incident_date',
411 freq: str | None = None,
412 **kwargs: Any) -> pd.DataFrame:
413 """Returns the percent change from one period to the next.
415 Args:
416 item: a dataset of events (if `freq` is set) or of counts or
417 rates in each period, in order.
418 periods: the number of periods to compare across (12 compares
419 each month with the same month of the year before). Defaults
420 to 1.
421 by: the column of groups whose counts are changed, for events.
422 Defaults to `None`.
423 date: the column of dates of events. Defaults to "incident_date".
424 freq: the length of each period, for events. Defaults to `None`,
425 in which case the data is already counts in each period.
426 **kwargs: not used.
428 Returns:
429 The change, in percent, with the index of the counts.
431 """
432 if freq is not None:
433 data = CrimeTrend().describe(item, by, date = date, freq = freq)
434 else:
435 data = item.data
436 return data.pct_change(periods = int(periods), fill_method = None) * 100
439@dataclasses.dataclass
440class Crosstab(amos.Describer):
441 """Counts (or shares) of two variables against each other.
443 Parameters (set in the "crosstab_parameters" section of the settings):
444 `rows`, `columns`, `normalize`, and `margins` (see `describe`).
446 """
448 """ Required Methods """
450 def describe(
451 self,
452 item: amos.Dataset,
453 rows: str | None = None,
454 columns: str | None = None,
455 normalize: bool | Literal['all', 'index', 'columns'] = False,
456 margins: Any = False,
457 **kwargs: Any) -> pd.DataFrame:
458 """Returns counts (or shares) of two variables against each other.
460 Args:
461 item: the dataset, with one row for each event.
462 rows: the column whose values become rows.
463 columns: the column whose values become columns.
464 normalize: False for counts, True for shares of all rows, "index"
465 for shares of each row, or "columns" for shares of each
466 column. Defaults to `False`.
467 margins: whether to add totals. Defaults to `False`.
468 **kwargs: not used.
470 Raises:
471 ValueError: if `rows` or `columns` is not set.
473 Returns:
474 The table.
476 """
477 if rows is None or columns is None: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 message = f'set the "rows" and "columns" of {self.name!r}'
479 raise ValueError(message)
480 if (isinstance(normalize, str) 480 ↛ 482line 480 didn't jump to line 482 because the condition on line 480 was never true
481 and normalize not in ('all', 'index', 'columns')):
482 normalize = utilities.to_bool(normalize)
483 data = item.data
484 return pd.crosstab(
485 data[rows], data[columns], normalize = normalize,
486 margins = utilities.to_bool(margins), dropna = True)
489@dataclasses.dataclass
490class Coverage(amos.Describer):
491 """The agencies that reported and the people they serve.
493 Agencies covered by another agency (whose crimes the other agency
494 reports) are left out, so that people are not counted twice.
496 Parameters (set in the "coverage_parameters" section of the settings):
497 `by` and `months` (see `describe`).
499 """
501 """ Required Methods """
503 def describe(
504 self,
505 item: amos.Dataset,
506 by: Any = ('data_year', 'state'),
507 months: Any = 12,
508 **kwargs: Any) -> pd.DataFrame:
509 """Returns how many agencies reported and how many people they serve.
511 Args:
512 item: a dataset of agencies, with "population" and
513 "months_active".
514 by: columns to group by. Defaults to ("data_year", "state").
515 months: the months that an agency must have reported in to count
516 as reporting for the year. Defaults to 12.
517 **kwargs: not used.
519 Returns:
520 A table with a column for each group, "agencies", "reporting"
521 (the agencies that reported in at least `months` months),
522 "population", "population_reporting", and "share_reporting"
523 (the share of the population served by agencies that
524 reported).
526 """
527 keys = utilities.to_names(by)
528 table = item.data.copy()
529 if 'covered_by_ori' in table.columns: 529 ↛ 531line 529 didn't jump to line 531 because the condition on line 529 was always true
530 table = table[table['covered_by_ori'].isna()]
531 reporting = table['months_active'].fillna(0) >= int(months)
532 table['_reporting'] = reporting.astype(int)
533 table['_population'] = table['population'].fillna(0)
534 table['_population_reporting'] = table['_population'].where(
535 reporting, 0)
536 summary = table.groupby(keys, observed = True).agg(
537 agencies = ('ori', 'nunique'),
538 reporting = ('_reporting', 'sum'),
539 population = ('_population', 'sum'),
540 population_reporting = (
541 '_population_reporting', 'sum')).reset_index()
542 share = summary['population_reporting'] / summary['population'].where(
543 summary['population'] > 0)
544 summary['share_reporting'] = share
545 return summary
548""" Private Functions """
551def _agencies_of(events: pd.DataFrame, folder: Any) -> pd.DataFrame:
552 """Loads the agencies of the years (and states) of `events`.
554 Args:
555 events: one row for each event, with "data_year" (and "state").
556 folder: the data folder.
558 Returns:
559 The agencies.
561 """
562 # `nibrs` is imported here, when agencies are needed, so that importing
563 # this module does not import the NIBRS readers.
564 from .nibrs import store # noqa: PLC0415
565 years = sorted(int(y) for y in events['data_year'].dropna().unique())
566 states = (
567 sorted(str(s) for s in events['state'].dropna().unique())
568 if 'state' in events.columns else None)
569 return store.load_frame(
570 'agencies', years, states = states, folder = folder, progress = False)