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