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

1"""Figures of crime data. 

2 

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"): 

9 

10```python 

11import crimepy 

12 

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``` 

18 

19Every function takes the parameters of `amos.Plot.apply`: "width" and 

20"height" (in inches), "title", "style", and "colors". 

21 

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`. 

31 

32""" 

33 

34from __future__ import annotations 

35 

36import dataclasses 

37from typing import Any, ClassVar 

38 

39import amos 

40import numpy as np 

41import pandas as pd 

42 

43from . import analyze, utilities 

44 

45_WEEKDAYS: tuple[str, ...] = ( 

46 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 

47 'Sunday') 

48 

49 

50""" Plots """ 

51 

52 

53@dataclasses.dataclass 

54class TrendPlot(amos.Plot): 

55 """Counts of events over time, as a line for each group. 

56 

57 Parameters (set in the "trend_plot_parameters" section of the settings, 

58 or passed to `trend`): 

59 

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. 

67 

68 """ 

69 

70 """ Required Methods """ 

71 

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. 

82 

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. 

91 

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) 

109 

110 

111@dataclasses.dataclass 

112class TopValues(amos.Plot): 

113 """The most common values of a column, as horizontal bars. 

114 

115 Parameters (set in the "top_values_parameters" section of the settings, 

116 or passed to `top_values`): 

117 

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`. 

123 

124 """ 

125 

126 size: ClassVar[tuple[float, float]] = (6.4, 5.6) 

127 

128 """ Required Methods """ 

129 

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. 

139 

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. 

149 

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)) 

160 

161 

162@dataclasses.dataclass 

163class TimeHeatmap(amos.Plot): 

164 """The number of events in each hour of each day of the week. 

165 

166 Parameters (set in the "time_heatmap_parameters" section of the 

167 settings, or passed to `time_heatmap`): 

168 

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. 

172 

173 """ 

174 

175 size: ClassVar[tuple[float, float]] = (7.2, 3.6) 

176 

177 """ Required Methods """ 

178 

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. 

187 

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. 

194 

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') 

207 

208 

209@dataclasses.dataclass 

210class AgePyramid(amos.Plot): 

211 """The ages of people by sex, as a pyramid. 

212 

213 Parameters (set in the "age_pyramid_parameters" section of the settings, 

214 or passed to `age_pyramid`): 

215 

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. 

220 

221 """ 

222 

223 """ Required Methods """ 

224 

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. 

233 

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. 

241 

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') 

259 

260 

261""" Public Functions """ 

262 

263 

264def trend(data: amos.Dataset | pd.DataFrame, **kwargs: Any) -> Any: 

265 """Draws counts over time (see `TrendPlot`) and returns the figure. 

266 

267 Args: 

268 data: one row for each event. 

269 **kwargs: parameters of `TrendPlot` and of `amos.Plot.apply`. 

270 

271 Returns: 

272 The `matplotlib` figure. 

273 

274 """ 

275 return _draw(TrendPlot(), data, **kwargs) 

276 

277 

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. 

283 

284 Args: 

285 data: the table. 

286 column: the column whose values are counted. 

287 **kwargs: parameters of `TopValues` and of `amos.Plot.apply`. 

288 

289 Returns: 

290 The `matplotlib` figure. 

291 

292 """ 

293 return _draw(TopValues(), data, column = column, **kwargs) 

294 

295 

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. 

298 

299 Args: 

300 data: one row for each event. 

301 **kwargs: parameters of `TimeHeatmap` and of `amos.Plot.apply`. 

302 

303 Returns: 

304 The `matplotlib` figure. 

305 

306 """ 

307 return _draw(TimeHeatmap(), data, **kwargs) 

308 

309 

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. 

315 

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`. 

320 

321 Returns: 

322 The `matplotlib` figure. 

323 

324 """ 

325 return _draw(AgePyramid(), data, person = person, **kwargs) 

326 

327 

328""" Private Functions """ 

329 

330 

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. 

336 

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. 

342 

343 Returns: 

344 The `matplotlib` figure. 

345 

346 """ 

347 dataset = plot.apply(utilities.to_dataset(data), **kwargs) 

348 return dataset.figures[plot.name] 

349 

350 

351def _readable(column: str) -> str: 

352 """Returns the name of a column as words. 

353 

354 Args: 

355 column: the name of a column, such as "offense_code". 

356 

357 Returns: 

358 The name with spaces for underscores and a capital first letter, 

359 such as "Offense code". 

360 

361 """ 

362 return column.replace('_', ' ').capitalize()