Coverage for src/crimepy/analyze.py: 100%

29 statements  

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

1"""Analyses of crime data that researchers often need. 

2 

3Each function applies one of the `amos` techniques of `crimepy.techniques`, 

4so the same analysis can be named in a project's settings. The functions 

5take an `amos.Dataset` or a data frame with one row for each event (an 

6incident, offense, victim, or arrest), such as those of `crimepy.nibrs.load` 

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

19Each function but `count` returns a table. Given a dataset, it also stores 

20the table in the dataset's `tables`, under the technique's name (such as 

21"clearance_rates"). 

22 

23Contents: 

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 

35from __future__ import annotations 

36 

37from collections.abc import Sequence 

38from typing import Any, Literal 

39 

40import amos 

41import pandas as pd 

42 

43from . import techniques, utilities 

44 

45""" Public Functions """ 

46 

47 

48def count( 

49 data: amos.Dataset | pd.DataFrame, 

50 by: Sequence[str] | str | None = None, 

51 *, 

52 date: str | None = None, 

53 freq: str | None = None, 

54 weight: str | None = None, 

55 name: str = 'count') -> amos.Dataset: 

56 """Returns the number of rows in each group (and period). 

57 

58 Applies `crimepy.techniques.CountCrimes` to a copy of the data, so a 

59 dataset that is passed keeps its rows. 

60 

61 Args: 

62 data: one row for each event. 

63 by: columns to group by. Defaults to `None`, which counts all rows 

64 (in each period, if `freq` is set). 

65 date: column of dates that `freq` groups by. Defaults to `None`, in 

66 which case it is "incident_date" if `freq` is set. 

67 freq: length of each period, as a `pandas` frequency ("YS" for years, 

68 "QS" for quarters, "MS" for months, "W" for weeks, "D" for days). 

69 Defaults to `None`, which does not group by period. 

70 weight: column whose values are summed instead of counting rows. 

71 Defaults to `None`. 

72 name: name of the column of counts. Defaults to "count". 

73 

74 Returns: 

75 A dataset with a column for each group (and "period") and the counts, 

76 which are its label. Groups with no rows are left out. 

77 

78 """ 

79 return techniques.CountCrimes().apply( 

80 utilities.to_dataset(utilities.to_frame(data)), label = name, by = by, 

81 date = date, freq = freq, weight = weight, name = name) 

82 

83 

84def trend( 

85 data: amos.Dataset | pd.DataFrame, 

86 by: str | None = None, 

87 *, 

88 date: str = 'incident_date', 

89 freq: str = 'MS', 

90 top: int | None = None) -> pd.DataFrame: 

91 """Returns counts over time, with one column for each group. 

92 

93 Args: 

94 data: one row for each event. 

95 by: column whose values become columns. Defaults to `None`, which 

96 makes one column, "count". 

97 date: column of dates. Defaults to "incident_date". 

98 freq: length of each period, as a `pandas` frequency. Defaults to 

99 "MS" (months). 

100 top: keep only the groups with the most rows. Defaults to `None`, 

101 which keeps them all. 

102 

103 Returns: 

104 A table indexed by the start of each period, with a column of counts 

105 for each group (0 in periods with none). 

106 

107 """ 

108 return _describe( 

109 techniques.CrimeTrend(), data, by = by, date = date, freq = freq, 

110 top = top) 

111 

112 

113def rates( 

114 counts: amos.Dataset | pd.DataFrame, 

115 population: amos.Dataset | pd.DataFrame | str, 

116 *, 

117 on: Sequence[str] | str | None = None, 

118 count: str = 'count', 

119 per: float = 100_000, 

120 name: str = 'rate') -> pd.DataFrame: 

121 """Returns counts per `per` people. 

122 

123 Args: 

124 counts: a table of counts (such as one from `count`). 

125 population: a column of `counts` with the population, or a table of 

126 populations to join to `counts` on `on`, with a "population" 

127 column. 

128 on: the columns that join `population` to `counts`. Defaults to 

129 `None`, in which case they are the columns that both have (other 

130 than "population"). 

131 count: the column of counts. Defaults to "count". 

132 per: the number of people that rates are per. Defaults to 100,000. 

133 name: name of the column of rates. Defaults to "rate". 

134 

135 Returns: 

136 `counts`, with the population (if it was joined) and the rates 

137 (missing where the population is 0 or missing). 

138 

139 """ 

140 return _describe( 

141 techniques.PopulationRates(), counts, population = population, on = on, 

142 count = count, per = per, name = name) 

143 

144 

145def crime_rates( 

146 events: amos.Dataset | pd.DataFrame, 

147 agencies: amos.Dataset | pd.DataFrame | None = None, 

148 by: Sequence[str] | str = ('data_year', 'state'), 

149 *, 

150 months: int = 12, 

151 per: float = 100_000, 

152 folder: Any = None) -> pd.DataFrame: 

153 """Returns the number of events per `per` people in each group. 

154 

155 Only agencies that reported in at least `months` months of the year (see 

156 "months_active" in `crimepy.nibrs.munge`) count, both for the events and 

157 for the population, so that the rate is not lowered by agencies that did 

158 not report. 

159 

160 Args: 

161 events: one row for each event (such as an incident), with 

162 "data_year" and "agency_ori" (or "ori"). 

163 agencies: the agencies, with "data_year", "ori", "population", and 

164 "months_active". Defaults to `None`, in which case the agencies 

165 of the years (and states) of `events` are loaded. 

166 by: columns of the agencies to group by. Defaults to ("data_year", 

167 "state"). 

168 months: the fewest months an agency must have reported in. Defaults 

169 to 12. 

170 per: the number of people that rates are per. Defaults to 100,000. 

171 folder: the data folder to load agencies from. Defaults to `None`, in 

172 which case it is `crimepy.utilities.data_folder()`. 

173 

174 Returns: 

175 A table with a column for each group, "count", "population", 

176 "agencies", and "rate". 

177 

178 """ 

179 return _describe( 

180 techniques.CrimeRates(), events, agencies = agencies, by = by, 

181 months = months, per = per, folder = folder) 

182 

183 

184def clearance_rates( 

185 incidents: amos.Dataset | pd.DataFrame, 

186 by: Sequence[str] | str | None = None, 

187 *, 

188 cleared: str = 'cleared') -> pd.DataFrame: 

189 """Returns the share of incidents that were cleared in each group. 

190 

191 Args: 

192 incidents: one row for each incident, with a column saying whether 

193 it was cleared (such as from `crimepy.nibrs.tables.incidents`). 

194 by: columns to group by. Defaults to `None`, which uses all rows. 

195 cleared: the column saying whether each incident was cleared. 

196 Defaults to "cleared". 

197 

198 Returns: 

199 A table with a column for each group, "incidents", "cleared", and 

200 "clearance_rate" (from 0 to 1). 

201 

202 """ 

203 return _describe( 

204 techniques.ClearanceRates(), incidents, by = by, cleared = cleared) 

205 

206 

207def percent_change( 

208 data: amos.Dataset | pd.DataFrame | pd.Series, 

209 periods: int = 1) -> pd.DataFrame | pd.Series: 

210 """Returns the percent change from one period to the next. 

211 

212 Args: 

213 data: counts (or rates) in each period, in order, such as a table 

214 from `trend`. 

215 periods: the number of periods to compare across (12 compares each 

216 month with the same month of the year before). Defaults to 1. 

217 

218 Returns: 

219 The change, in percent, with the index of `data`. 

220 

221 """ 

222 if isinstance(data, pd.Series): 

223 return data.pct_change(periods = periods, fill_method = None) * 100 

224 return _describe(techniques.PercentChange(), data, periods = periods) 

225 

226 

227def crosstab( 

228 data: amos.Dataset | pd.DataFrame, 

229 rows: str, 

230 columns: str, 

231 *, 

232 normalize: bool | Literal['all', 'index', 'columns'] = False, 

233 margins: bool = False) -> pd.DataFrame: 

234 """Returns counts (or shares) of two variables against each other. 

235 

236 Args: 

237 data: one row for each event. 

238 rows: the column whose values become rows. 

239 columns: the column whose values become columns. 

240 normalize: False for counts, True for shares of all rows, "index" 

241 for shares of each row, or "columns" for shares of each column. 

242 Defaults to `False`. 

243 margins: whether to add totals. Defaults to `False`. 

244 

245 Returns: 

246 The table. 

247 

248 """ 

249 return _describe( 

250 techniques.Crosstab(), data, rows = rows, columns = columns, 

251 normalize = normalize, margins = margins) 

252 

253 

254def coverage( 

255 agencies: amos.Dataset | pd.DataFrame, 

256 by: Sequence[str] | str = ('data_year', 'state'), 

257 *, 

258 months: int = 12) -> pd.DataFrame: 

259 """Returns how many agencies reported and how many people they serve. 

260 

261 Agencies covered by another agency (whose crimes the other agency 

262 reports) are left out, so that people are not counted twice. 

263 

264 Args: 

265 agencies: the agencies, with "population" and "months_active". 

266 by: columns to group by. Defaults to ("data_year", "state"). 

267 months: the months that an agency must have reported in to count as 

268 reporting for the year. Defaults to 12. 

269 

270 Returns: 

271 A table with a column for each group, "agencies", "reporting" (the 

272 agencies that reported in at least `months` months), 

273 "population", "population_reporting", and "share_reporting" (the 

274 share of the population served by agencies that reported). 

275 

276 """ 

277 return _describe(techniques.Coverage(), agencies, by = by, months = months) 

278 

279 

280""" Private Functions """ 

281 

282 

283def _describe( 

284 technique: amos.Describer, 

285 data: amos.Dataset | pd.DataFrame, 

286 **kwargs: Any) -> pd.DataFrame: 

287 """Applies a describer to `data` and returns its table. 

288 

289 Args: 

290 technique: the describer. 

291 data: a dataset (which keeps the table in its `tables`) or a data 

292 frame. 

293 **kwargs: parameters for the describer. 

294 

295 Returns: 

296 The table. 

297 

298 """ 

299 dataset = technique.apply(utilities.to_dataset(data), **kwargs) 

300 return dataset.tables[technique.name]