Coverage for src/crimepy/plot.py: 99%
82 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"""Figures of crime data.
3Each figure is an `amos.Plot`, so it can be named in the "artist" stage of
4an `amos` project's settings, and it has `amos`'s style for scientific
5figures (with colors that people with color blindness can tell apart). Each
6also has a function that draws it from an `amos.Dataset` (or a data frame)
7and returns the `matplotlib` figure. A dataset also keeps the figure in its
8`figures`, under the plot's name (such as "trend_plot"):
10```python
11import crimepy
13incidents = crimepy.nibrs.tables.build('incidents', '2021-2023', states = 'DE')
14figure = crimepy.plot.trend(incidents, by = 'offense_category', top = 5)
15figure.savefig('trend.png')
16incidents.figures['trend_plot']
17```
19Every function takes the parameters of `amos.Plot.apply`: "width" and
20"height" (in inches), "title", "style", and "colors".
22Contents:
23 AgePyramid: ages of people by sex, as a pyramid.
24 TimeHeatmap: events by hour of the day and day of the week.
25 TopValues: the most common values of a column, as bars.
26 TrendPlot: counts over time, as lines.
27 age_pyramid: draws an `AgePyramid`.
28 time_heatmap: draws a `TimeHeatmap`.
29 top_values: draws a `TopValues`.
30 trend: draws a `TrendPlot`.
32"""
34from __future__ import annotations
36import dataclasses
37from typing import Any, ClassVar
39import amos
40import numpy as np
41import pandas as pd
43from . import analyze, utilities
45_WEEKDAYS: tuple[str, ...] = (
46 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
47 'Sunday')
50""" Plots """
53@dataclasses.dataclass
54class TrendPlot(amos.Plot):
55 """Counts of events over time, as a line for each group.
57 Parameters (set in the "trend_plot_parameters" section of the settings,
58 or passed to `trend`):
60 * `date`: the column of dates. Defaults to "incident_date".
61 * `freq`: the length of each period, as a `pandas` frequency ("YS",
62 "QS", "MS", "W", or "D"). Defaults to "MS" (months).
63 * `by`: a column whose values each get a line. Defaults to `None`, which
64 draws one line.
65 * `top`: the number of groups with the most events to draw. Defaults to
66 6.
68 """
70 """ Required Methods """
72 def draw(
73 self,
74 item: amos.Dataset,
75 figure: Any,
76 date: str = 'incident_date',
77 freq: str = 'MS',
78 by: str | None = None,
79 top: int | None = 6,
80 **kwargs: Any) -> None:
81 """Draws the counts over time.
83 Args:
84 item: the dataset, with one row for each event.
85 figure: the figure to draw on.
86 date: the column of dates. Defaults to "incident_date".
87 freq: the length of each period. Defaults to "MS".
88 by: the column of groups. Defaults to `None`.
89 top: the number of groups to draw. Defaults to 6.
90 **kwargs: not used.
92 """
93 table = analyze.trend(
94 item.data,
95 by,
96 date = date,
97 freq = freq,
98 top = top)
99 axes = figure.add_subplot()
100 for column in table.columns:
101 axes.plot(table.index, table[column], label = column)
102 axes.set_xlabel('')
103 axes.set_ylabel('Count')
104 figure.autofmt_xdate()
105 if by is not None: 105 ↛ exitline 105 didn't return from function 'draw' because the condition on line 105 was always true
106 figure.legend(
107 title = _readable(by), fontsize = 'small',
108 loc = 'outside lower center', ncols = 2)
111@dataclasses.dataclass
112class TopValues(amos.Plot):
113 """The most common values of a column, as horizontal bars.
115 Parameters (set in the "top_values_parameters" section of the settings,
116 or passed to `top_values`):
118 * `column`: the column whose values are counted. Defaults to
119 "offense_code".
120 * `limit`: the number of values to draw. Defaults to 10.
121 * `share`: whether to draw the share of rows instead of the count.
122 Defaults to `False`.
124 """
126 size: ClassVar[tuple[float, float]] = (6.4, 5.6)
128 """ Required Methods """
130 def draw(
131 self,
132 item: amos.Dataset,
133 figure: Any,
134 column: str = 'offense_code',
135 limit: int = 10,
136 share: bool = False,
137 **kwargs: Any) -> None:
138 """Draws the most common values.
140 Args:
141 item: the dataset.
142 figure: the figure to draw on.
143 column: the column whose values are counted. Defaults to
144 "offense_code".
145 limit: the number of values to draw. Defaults to 10.
146 share: whether to draw shares instead of counts. Defaults to
147 `False`.
148 **kwargs: not used.
150 """
151 values = item.data[column]
152 counts = (
153 values.value_counts(normalize = True) if share
154 else values.value_counts()).head(limit)
155 counts = counts[::-1]
156 axes = figure.add_subplot()
157 axes.barh([str(v) for v in counts.index], counts.to_numpy())
158 axes.set_xlabel('Share' if share else 'Count')
159 axes.set_ylabel(_readable(column))
162@dataclasses.dataclass
163class TimeHeatmap(amos.Plot):
164 """The number of events in each hour of each day of the week.
166 Parameters (set in the "time_heatmap_parameters" section of the
167 settings, or passed to `time_heatmap`):
169 * `date`: the column of dates. Defaults to "incident_date".
170 * `hour`: the column of hours (0 to 23). Defaults to "incident_hour".
171 Events with no hour are left out.
173 """
175 size: ClassVar[tuple[float, float]] = (7.2, 3.6)
177 """ Required Methods """
179 def draw(
180 self,
181 item: amos.Dataset,
182 figure: Any,
183 date: str = 'incident_date',
184 hour: str = 'incident_hour',
185 **kwargs: Any) -> None:
186 """Draws the heatmap.
188 Args:
189 item: the dataset.
190 figure: the figure to draw on.
191 date: the column of dates. Defaults to "incident_date".
192 hour: the column of hours. Defaults to "incident_hour".
193 **kwargs: not used.
195 """
196 data = item.data[[date, hour]].dropna()
197 weekdays = pd.to_datetime(data[date]).dt.dayofweek.to_numpy()
198 hours = data[hour].astype(int).to_numpy()
199 grid = np.zeros((7, 24))
200 np.add.at(grid, (weekdays, hours), 1)
201 axes = figure.add_subplot()
202 image = axes.imshow(grid, aspect = 'auto', cmap = 'viridis')
203 axes.set_yticks(range(7), [d[:3] for d in _WEEKDAYS])
204 axes.set_xticks(range(0, 24, 3), [f'{h:02d}' for h in range(0, 24, 3)])
205 axes.set_xlabel('Hour of the day')
206 figure.colorbar(image, ax = axes, label = 'Count')
209@dataclasses.dataclass
210class AgePyramid(amos.Plot):
211 """The ages of people by sex, as a pyramid.
213 Parameters (set in the "age_pyramid_parameters" section of the settings,
214 or passed to `age_pyramid`):
216 * `person`: whose ages to draw: "victim", "offender", or "arrestee"
217 (which use the columns "victim_age" and "victim_sex", and so on).
218 Defaults to "victim".
219 * `width_of_bins`: the number of years in each bar. Defaults to 5.
221 """
223 """ Required Methods """
225 def draw(
226 self,
227 item: amos.Dataset,
228 figure: Any,
229 person: str = 'victim',
230 width_of_bins: int = 5,
231 **kwargs: Any) -> None:
232 """Draws the pyramid.
234 Args:
235 item: the dataset.
236 figure: the figure to draw on.
237 person: "victim", "offender", or "arrestee". Defaults to
238 "victim".
239 width_of_bins: the number of years in each bar. Defaults to 5.
240 **kwargs: not used.
242 """
243 data = item.data[[f'{person}_age', f'{person}_sex']].dropna()
244 sexes = data[f'{person}_sex'].astype('object').replace(
245 {'M': 'Male', 'F': 'Female'})
246 edges = np.arange(0, 100 + width_of_bins, width_of_bins)
247 axes = figure.add_subplot()
248 for sex, sign in (('Male', -1), ('Female', 1)):
249 ages = data.loc[sexes == sex, f'{person}_age'].astype(float)
250 counts, _ = np.histogram(ages, bins = edges)
251 axes.barh(
252 edges[:-1], sign * counts, height = width_of_bins * 0.9,
253 align = 'edge', label = sex)
254 ticks = axes.get_xticks()
255 axes.set_xticks(ticks, [f'{abs(t):,.0f}' for t in ticks])
256 axes.set_xlabel('Count')
257 axes.set_ylabel(f'Age of {person}')
258 axes.legend(fontsize = 'small')
261""" Public Functions """
264def trend(data: amos.Dataset | pd.DataFrame, **kwargs: Any) -> Any:
265 """Draws counts over time (see `TrendPlot`) and returns the figure.
267 Args:
268 data: one row for each event.
269 **kwargs: parameters of `TrendPlot` and of `amos.Plot.apply`.
271 Returns:
272 The `matplotlib` figure.
274 """
275 return _draw(TrendPlot(), data, **kwargs)
278def top_values(
279 data: amos.Dataset | pd.DataFrame,
280 column: str,
281 **kwargs: Any) -> Any:
282 """Draws the most common values of `column` and returns the figure.
284 Args:
285 data: the table.
286 column: the column whose values are counted.
287 **kwargs: parameters of `TopValues` and of `amos.Plot.apply`.
289 Returns:
290 The `matplotlib` figure.
292 """
293 return _draw(TopValues(), data, column = column, **kwargs)
296def time_heatmap(data: amos.Dataset | pd.DataFrame, **kwargs: Any) -> Any:
297 """Draws events by hour and day of the week and returns the figure.
299 Args:
300 data: one row for each event.
301 **kwargs: parameters of `TimeHeatmap` and of `amos.Plot.apply`.
303 Returns:
304 The `matplotlib` figure.
306 """
307 return _draw(TimeHeatmap(), data, **kwargs)
310def age_pyramid(
311 data: amos.Dataset | pd.DataFrame,
312 person: str = 'victim',
313 **kwargs: Any) -> Any:
314 """Draws the ages of people by sex and returns the figure.
316 Args:
317 data: one row for each person (such as victims).
318 person: "victim", "offender", or "arrestee". Defaults to "victim".
319 **kwargs: parameters of `AgePyramid` and of `amos.Plot.apply`.
321 Returns:
322 The `matplotlib` figure.
324 """
325 return _draw(AgePyramid(), data, person = person, **kwargs)
328""" Private Functions """
331def _draw(
332 plot: amos.Plot,
333 data: amos.Dataset | pd.DataFrame,
334 **kwargs: Any) -> Any:
335 """Draws `plot` of `data` and returns the figure.
337 Args:
338 plot: the plot.
339 data: the data to draw. A dataset keeps the figure in its
340 `figures`, under the plot's name.
341 **kwargs: parameters for the plot.
343 Returns:
344 The `matplotlib` figure.
346 """
347 dataset = plot.apply(utilities.to_dataset(data), **kwargs)
348 return dataset.figures[plot.name]
351def _readable(column: str) -> str:
352 """Returns the name of a column as words.
354 Args:
355 column: the name of a column, such as "offense_code".
357 Returns:
358 The name with spaces for underscores and a capital first letter,
359 such as "Offense code".
361 """
362 return column.replace('_', ' ').capitalize()