neissai 1.4.0__tar.gz
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.
- neissai-1.4.0/LICENSE +21 -0
- neissai-1.4.0/PKG-INFO +139 -0
- neissai-1.4.0/README.md +112 -0
- neissai-1.4.0/pyproject.toml +40 -0
- neissai-1.4.0/setup.cfg +4 -0
- neissai-1.4.0/src/neissai/__init__.py +22 -0
- neissai-1.4.0/src/neissai/circumstances.py +73 -0
- neissai-1.4.0/src/neissai/classifier.py +102 -0
- neissai-1.4.0/src/neissai/cli.py +98 -0
- neissai-1.4.0/src/neissai/devices.py +124 -0
- neissai-1.4.0/src/neissai/estimate.py +95 -0
- neissai-1.4.0/src/neissai/export.py +76 -0
- neissai-1.4.0/src/neissai/io.py +154 -0
- neissai-1.4.0/src/neissai/model/ebike_moped_v1.json +1 -0
- neissai-1.4.0/src/neissai.egg-info/PKG-INFO +139 -0
- neissai-1.4.0/src/neissai.egg-info/SOURCES.txt +19 -0
- neissai-1.4.0/src/neissai.egg-info/dependency_links.txt +1 -0
- neissai-1.4.0/src/neissai.egg-info/entry_points.txt +2 -0
- neissai-1.4.0/src/neissai.egg-info/requires.txt +11 -0
- neissai-1.4.0/src/neissai.egg-info/top_level.txt +1 -0
- neissai-1.4.0/tests/test_neissai.py +121 -0
neissai-1.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Subash Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
neissai-1.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: neissai
|
|
3
|
+
Version: 1.4.0
|
|
4
|
+
Summary: AI-assisted national injury estimates from NEISS emergency department narratives: consistent e-bike and e-scooter counts across product-code changes, with survey-correct confidence intervals.
|
|
5
|
+
Author: Subash Kumar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/subash28kumar/neiss-ai
|
|
8
|
+
Project-URL: Dashboard, https://subash28kumar.github.io/neiss-ai/
|
|
9
|
+
Project-URL: Documentation, https://github.com/subash28kumar/neiss-ai#readme
|
|
10
|
+
Project-URL: Changelog, https://github.com/subash28kumar/neiss-ai/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: NEISS,injury surveillance,e-bike,e-scooter,micromobility,text classification,survey statistics
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: pandas>=1.5
|
|
19
|
+
Requires-Dist: numpy>=1.22
|
|
20
|
+
Provides-Extra: train
|
|
21
|
+
Requires-Dist: scikit-learn>=1.1; extra == "train"
|
|
22
|
+
Provides-Extra: excel
|
|
23
|
+
Requires-Dist: openpyxl; extra == "excel"
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest; extra == "test"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# NEISS-AI
|
|
29
|
+
|
|
30
|
+
**AI-assisted national injury estimates from US emergency department narratives.**
|
|
31
|
+
|
|
32
|
+
NEISS-AI turns the US Consumer Product Safety Commission's [NEISS](https://www.cpsc.gov/Research--Statistics/NEISS-Injury-Data) data into consistent national estimates for e-bikes, e-scooters, bicycles and other micromobility devices, with survey-correct 95% confidence intervals.
|
|
33
|
+
|
|
34
|
+
**Live web app:** https://subash28kumar.github.io/neiss-ai/
|
|
35
|
+
|
|
36
|
+
The web app has four parts, all free and running in the browser:
|
|
37
|
+
|
|
38
|
+
- **Overview:** headline numbers and trends for e-bikes, e-scooters and kick scooters.
|
|
39
|
+
- **Ask a question:** type a question in plain English (for example, *"How many teenagers broke a bone on an e-scooter in 2025?"*) and get a national estimate with a 95% confidence interval, a trend chart and an "Understood as" line showing how the question was read.
|
|
40
|
+
- **Explore the data:** filter by device, years, age, sex, injury, body part, outcome and circumstance; download the results as CSV; share a link to any view.
|
|
41
|
+
- **Search injury stories:** search the hospital narratives (for example *battery*, *hit by car*) to see national estimates by year and real example stories.
|
|
42
|
+
|
|
43
|
+
The site refreshes itself: a GitHub Actions workflow (`.github/workflows/refresh.yml`) checks monthly for a new year of NEISS data, reruns the tool and republishes the site. It never publishes fewer years than the site already shows.
|
|
44
|
+
|
|
45
|
+
## Why it exists
|
|
46
|
+
|
|
47
|
+
In January 2024, CPSC gave e-bikes their own product code (5045). Before then, e-bikes shared code 3215 ("mopeds or power-assisted cycles") with gas mopeds. Counts based on codes alone therefore jump at 2024 and cannot show a true trend.
|
|
48
|
+
|
|
49
|
+
NEISS-AI reads the short injury story in each record:
|
|
50
|
+
|
|
51
|
+
1. **Rules.** Product codes and whole-word keywords label each injury (e-bike, e-scooter, bicycle, kick scooter, hoverboard, skateboard or moped). Whole-word matching matters: "the bike" is not an "e bike".
|
|
52
|
+
2. **Text classifier.** For code-3215 records whose story is unclear, a classifier trained on 2024-2025 records (where 5045 and 5046 are separate) decides e-bike vs moped. It is TF-IDF + logistic regression, stored as plain JSON and run with numpy only. Five-fold cross-validated AUC: **0.995**.
|
|
53
|
+
3. **National estimates.** NEISS is a stratified sample of hospitals. Estimates use the survey weights and Taylor-linearized standard errors (hospitals as clusters within strata), and are flagged "unstable" by CPSC's rule (fewer than 20 records, an estimate below 1,200, or a coefficient of variation above 33%).
|
|
54
|
+
|
|
55
|
+
**Validation:** e-bike estimates for 2022-2024 are within 4% of CPSC's published figures (27,800 vs 26,800; 37,600 vs 37,600; 59,000 vs 59,200).
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
pip install git+https://github.com/subash28kumar/neiss-ai
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.9+, pandas and numpy. (scikit-learn is needed only to retrain the classifier: `pip install "neissai[train] @ git+https://github.com/subash28kumar/neiss-ai"`.)
|
|
64
|
+
|
|
65
|
+
## Use from the command line
|
|
66
|
+
|
|
67
|
+
Download the annual NEISS files (for example `neiss2019.tsv` ... `neiss2025.tsv`) from the [CPSC NEISS page](https://www.cpsc.gov/Research--Statistics/NEISS-Injury-Data) into a folder called `data`, or try `neissai download --years 2019-2025 --data-dir data`. Then:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
neissai micromobility --years 2019-2025 --data-dir data --out-dir results
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This prints a table of national estimates and saves:
|
|
74
|
+
|
|
75
|
+
| File | Contents |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `estimates_by_year_device.csv` | national ED visits by year and device, 95% CI, CV, records, unstable flag |
|
|
78
|
+
| `estimates_by_year_device_age.csv` | the same by age group (0-17, 18-64, 65+) |
|
|
79
|
+
| `shares_by_device.csv` | weighted shares hospitalized, head/face injury, motor vehicle involved, no helmet stated, alcohol |
|
|
80
|
+
| `micromobility_records.csv` | every labelled record, with device, label source and classifier probability |
|
|
81
|
+
|
|
82
|
+
Check a single story:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
neissai classify "14YOM FELL OFF HIS ELECTRIC BIKE GOING 20MPH"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Use from Python
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import neissai
|
|
92
|
+
|
|
93
|
+
df = neissai.read_neiss("data", years="2019-2025")
|
|
94
|
+
df = neissai.add_outcomes(neissai.label_devices(df))
|
|
95
|
+
mm = df["device"].isin(neissai.DEVICES)
|
|
96
|
+
|
|
97
|
+
# Pass the full table plus a filter, so the variance uses the whole sample design
|
|
98
|
+
by_year = neissai.national_estimates(df, by=["year", "device"], where=mm)
|
|
99
|
+
kids = neissai.national_estimates(df, by=["year"], where=(df["device"] == "E-bike") & (df["age_group"] == "0-17"))
|
|
100
|
+
hosp = neissai.shares(df, "hospitalized", by=["device", "age_group"], where=mm)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Rebuild the website data
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
python scripts/update_site.py --data-dir data --years 2019-2025
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Retrain the classifier
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
python scripts/train_classifier.py --data-dir data --years 2024-2025
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Limits
|
|
116
|
+
|
|
117
|
+
NEISS counts emergency department visits, not riders or miles, so results are counts, not risk rates. Injuries treated elsewhere and deaths at the scene are not included. Helmet use is only what the story mentions. Early e-bike estimates (2019-2021) rest on small samples. The powered-scooter code may include some seated, moped-style scooters.
|
|
118
|
+
|
|
119
|
+
## Cite
|
|
120
|
+
|
|
121
|
+
If you use NEISS-AI, please cite the software (see `CITATION.cff` or "Cite this repository" on GitHub):
|
|
122
|
+
|
|
123
|
+
> Kumar S. NEISS-AI: AI-assisted national injury estimates from NEISS emergency department narratives. Version 1.4.0 [software]. Zenodo; 2026. https://doi.org/10.5281/zenodo.23550272
|
|
124
|
+
>
|
|
125
|
+
> To cite all versions, use https://doi.org/10.5281/zenodo.23306744 (always resolves to the latest version).
|
|
126
|
+
|
|
127
|
+
and the method paper:
|
|
128
|
+
|
|
129
|
+
> Kumar S. E-bike injuries among US children surged in 2025: national estimates from emergency department reports, 2019-2025, using machine learning to read injury narratives [preprint]. Zenodo; 2026. https://doi.org/10.5281/zenodo.23354066
|
|
130
|
+
|
|
131
|
+
and the labeled dataset:
|
|
132
|
+
|
|
133
|
+
> Kumar S. NEISS-AI device labels for US micromobility injuries, 2019–2025 [dataset]. Version 1.1. Zenodo; 2026. https://doi.org/10.5281/zenodo.23550382
|
|
134
|
+
|
|
135
|
+
Data come from the US Consumer Product Safety Commission. CPSC does not endorse this tool.
|
|
136
|
+
|
|
137
|
+
## Licence
|
|
138
|
+
|
|
139
|
+
MIT. Built by Subash Kumar (ORCID [0009-0003-6661-3424](https://orcid.org/0009-0003-6661-3424)).
|
neissai-1.4.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# NEISS-AI
|
|
2
|
+
|
|
3
|
+
**AI-assisted national injury estimates from US emergency department narratives.**
|
|
4
|
+
|
|
5
|
+
NEISS-AI turns the US Consumer Product Safety Commission's [NEISS](https://www.cpsc.gov/Research--Statistics/NEISS-Injury-Data) data into consistent national estimates for e-bikes, e-scooters, bicycles and other micromobility devices, with survey-correct 95% confidence intervals.
|
|
6
|
+
|
|
7
|
+
**Live web app:** https://subash28kumar.github.io/neiss-ai/
|
|
8
|
+
|
|
9
|
+
The web app has four parts, all free and running in the browser:
|
|
10
|
+
|
|
11
|
+
- **Overview:** headline numbers and trends for e-bikes, e-scooters and kick scooters.
|
|
12
|
+
- **Ask a question:** type a question in plain English (for example, *"How many teenagers broke a bone on an e-scooter in 2025?"*) and get a national estimate with a 95% confidence interval, a trend chart and an "Understood as" line showing how the question was read.
|
|
13
|
+
- **Explore the data:** filter by device, years, age, sex, injury, body part, outcome and circumstance; download the results as CSV; share a link to any view.
|
|
14
|
+
- **Search injury stories:** search the hospital narratives (for example *battery*, *hit by car*) to see national estimates by year and real example stories.
|
|
15
|
+
|
|
16
|
+
The site refreshes itself: a GitHub Actions workflow (`.github/workflows/refresh.yml`) checks monthly for a new year of NEISS data, reruns the tool and republishes the site. It never publishes fewer years than the site already shows.
|
|
17
|
+
|
|
18
|
+
## Why it exists
|
|
19
|
+
|
|
20
|
+
In January 2024, CPSC gave e-bikes their own product code (5045). Before then, e-bikes shared code 3215 ("mopeds or power-assisted cycles") with gas mopeds. Counts based on codes alone therefore jump at 2024 and cannot show a true trend.
|
|
21
|
+
|
|
22
|
+
NEISS-AI reads the short injury story in each record:
|
|
23
|
+
|
|
24
|
+
1. **Rules.** Product codes and whole-word keywords label each injury (e-bike, e-scooter, bicycle, kick scooter, hoverboard, skateboard or moped). Whole-word matching matters: "the bike" is not an "e bike".
|
|
25
|
+
2. **Text classifier.** For code-3215 records whose story is unclear, a classifier trained on 2024-2025 records (where 5045 and 5046 are separate) decides e-bike vs moped. It is TF-IDF + logistic regression, stored as plain JSON and run with numpy only. Five-fold cross-validated AUC: **0.995**.
|
|
26
|
+
3. **National estimates.** NEISS is a stratified sample of hospitals. Estimates use the survey weights and Taylor-linearized standard errors (hospitals as clusters within strata), and are flagged "unstable" by CPSC's rule (fewer than 20 records, an estimate below 1,200, or a coefficient of variation above 33%).
|
|
27
|
+
|
|
28
|
+
**Validation:** e-bike estimates for 2022-2024 are within 4% of CPSC's published figures (27,800 vs 26,800; 37,600 vs 37,600; 59,000 vs 59,200).
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install git+https://github.com/subash28kumar/neiss-ai
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.9+, pandas and numpy. (scikit-learn is needed only to retrain the classifier: `pip install "neissai[train] @ git+https://github.com/subash28kumar/neiss-ai"`.)
|
|
37
|
+
|
|
38
|
+
## Use from the command line
|
|
39
|
+
|
|
40
|
+
Download the annual NEISS files (for example `neiss2019.tsv` ... `neiss2025.tsv`) from the [CPSC NEISS page](https://www.cpsc.gov/Research--Statistics/NEISS-Injury-Data) into a folder called `data`, or try `neissai download --years 2019-2025 --data-dir data`. Then:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
neissai micromobility --years 2019-2025 --data-dir data --out-dir results
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This prints a table of national estimates and saves:
|
|
47
|
+
|
|
48
|
+
| File | Contents |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `estimates_by_year_device.csv` | national ED visits by year and device, 95% CI, CV, records, unstable flag |
|
|
51
|
+
| `estimates_by_year_device_age.csv` | the same by age group (0-17, 18-64, 65+) |
|
|
52
|
+
| `shares_by_device.csv` | weighted shares hospitalized, head/face injury, motor vehicle involved, no helmet stated, alcohol |
|
|
53
|
+
| `micromobility_records.csv` | every labelled record, with device, label source and classifier probability |
|
|
54
|
+
|
|
55
|
+
Check a single story:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
neissai classify "14YOM FELL OFF HIS ELECTRIC BIKE GOING 20MPH"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Use from Python
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import neissai
|
|
65
|
+
|
|
66
|
+
df = neissai.read_neiss("data", years="2019-2025")
|
|
67
|
+
df = neissai.add_outcomes(neissai.label_devices(df))
|
|
68
|
+
mm = df["device"].isin(neissai.DEVICES)
|
|
69
|
+
|
|
70
|
+
# Pass the full table plus a filter, so the variance uses the whole sample design
|
|
71
|
+
by_year = neissai.national_estimates(df, by=["year", "device"], where=mm)
|
|
72
|
+
kids = neissai.national_estimates(df, by=["year"], where=(df["device"] == "E-bike") & (df["age_group"] == "0-17"))
|
|
73
|
+
hosp = neissai.shares(df, "hospitalized", by=["device", "age_group"], where=mm)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Rebuild the website data
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
python scripts/update_site.py --data-dir data --years 2019-2025
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Retrain the classifier
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
python scripts/train_classifier.py --data-dir data --years 2024-2025
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Limits
|
|
89
|
+
|
|
90
|
+
NEISS counts emergency department visits, not riders or miles, so results are counts, not risk rates. Injuries treated elsewhere and deaths at the scene are not included. Helmet use is only what the story mentions. Early e-bike estimates (2019-2021) rest on small samples. The powered-scooter code may include some seated, moped-style scooters.
|
|
91
|
+
|
|
92
|
+
## Cite
|
|
93
|
+
|
|
94
|
+
If you use NEISS-AI, please cite the software (see `CITATION.cff` or "Cite this repository" on GitHub):
|
|
95
|
+
|
|
96
|
+
> Kumar S. NEISS-AI: AI-assisted national injury estimates from NEISS emergency department narratives. Version 1.4.0 [software]. Zenodo; 2026. https://doi.org/10.5281/zenodo.23550272
|
|
97
|
+
>
|
|
98
|
+
> To cite all versions, use https://doi.org/10.5281/zenodo.23306744 (always resolves to the latest version).
|
|
99
|
+
|
|
100
|
+
and the method paper:
|
|
101
|
+
|
|
102
|
+
> Kumar S. E-bike injuries among US children surged in 2025: national estimates from emergency department reports, 2019-2025, using machine learning to read injury narratives [preprint]. Zenodo; 2026. https://doi.org/10.5281/zenodo.23354066
|
|
103
|
+
|
|
104
|
+
and the labeled dataset:
|
|
105
|
+
|
|
106
|
+
> Kumar S. NEISS-AI device labels for US micromobility injuries, 2019–2025 [dataset]. Version 1.1. Zenodo; 2026. https://doi.org/10.5281/zenodo.23550382
|
|
107
|
+
|
|
108
|
+
Data come from the US Consumer Product Safety Commission. CPSC does not endorse this tool.
|
|
109
|
+
|
|
110
|
+
## Licence
|
|
111
|
+
|
|
112
|
+
MIT. Built by Subash Kumar (ORCID [0009-0003-6661-3424](https://orcid.org/0009-0003-6661-3424)).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "neissai"
|
|
7
|
+
version = "1.4.0"
|
|
8
|
+
description = "AI-assisted national injury estimates from NEISS emergency department narratives: consistent e-bike and e-scooter counts across product-code changes, with survey-correct confidence intervals."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Subash Kumar"}]
|
|
14
|
+
keywords = ["NEISS", "injury surveillance", "e-bike", "e-scooter", "micromobility", "text classification", "survey statistics"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["pandas>=1.5", "numpy>=1.22"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
train = ["scikit-learn>=1.1"]
|
|
24
|
+
excel = ["openpyxl"]
|
|
25
|
+
test = ["pytest"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/subash28kumar/neiss-ai"
|
|
29
|
+
Dashboard = "https://subash28kumar.github.io/neiss-ai/"
|
|
30
|
+
Documentation = "https://github.com/subash28kumar/neiss-ai#readme"
|
|
31
|
+
Changelog = "https://github.com/subash28kumar/neiss-ai/blob/main/CHANGELOG.md"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
neissai = "neissai.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
neissai = ["model/*.json"]
|
neissai-1.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""neissai: AI-assisted national injury estimates from NEISS emergency department narratives.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import neissai
|
|
6
|
+
df = neissai.read_neiss("data", years="2019-2025") # CPSC annual files
|
|
7
|
+
labeled = neissai.label_devices(df) # rules + text classifier
|
|
8
|
+
labeled = neissai.add_outcomes(labeled)
|
|
9
|
+
est = neissai.national_estimates(labeled, by=["year", "device"],
|
|
10
|
+
where=labeled["device"].isin(neissai.DEVICES))
|
|
11
|
+
"""
|
|
12
|
+
from .classifier import EbikeMopedClassifier
|
|
13
|
+
from . import circumstances
|
|
14
|
+
from .devices import DEVICES, add_outcomes, label_devices, micromobility
|
|
15
|
+
from .estimate import national_estimates, proportion, shares, total
|
|
16
|
+
from .io import download, parse_years, read_neiss, read_neiss_file, standardize
|
|
17
|
+
|
|
18
|
+
__version__ = "1.4.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["DEVICES", "circumstances", "EbikeMopedClassifier", "add_outcomes", "download", "label_devices",
|
|
21
|
+
"micromobility", "national_estimates", "parse_years", "proportion", "read_neiss",
|
|
22
|
+
"read_neiss_file", "shares", "standardize", "total", "__version__"]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""InjuryCoder: circumstance flags read from NEISS injury narratives (added in v1.4).
|
|
2
|
+
|
|
3
|
+
NEISS does not code whether a motor vehicle was involved or whether the rider wore a helmet,
|
|
4
|
+
but the narrative often says so. These rules read the narrative text:
|
|
5
|
+
|
|
6
|
+
* ``motor_vehicle(text)`` -> True when the patient was hit by, crashed into, or struck part of
|
|
7
|
+
a motor vehicle (moving or parked, including a car door), or the narrative calls it an MVC/MVA.
|
|
8
|
+
Swerving to avoid a car without contact is not counted.
|
|
9
|
+
* ``helmet(text)`` -> "yes", "no" or "not mentioned". "Not mentioned" includes unclear wording
|
|
10
|
+
such as "HELMET NS" and is never treated as "no".
|
|
11
|
+
|
|
12
|
+
Validation (held-out test of 300 random narratives, 2019-2025; see VALIDATION.md): motor vehicle
|
|
13
|
+
accuracy 98.3%, sensitivity 93.6%, precision 95.7%; helmet 300/300 correct.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
MOD = r"(?:(?:A|AN|THE|HIS|HER|MOVING|PARKED|ONCOMING|PASSING|GARBAGE|DUMP|BOX|PICKUP|PICK-UP|DELIVERY|POLICE|SCHOOL|CITY|SEMI|TOW|FIRE|ANOTHER|SMALL|LARGE|BIG|WHITE|BLACK|RED|SLOW|SLOW-MOVING|REVERSING|BACKING|TURNING|STATIONARY|[A-Z]+'S|STOPPED|SPEEDING|DADS|MOMS|DAD'S|MOM'S|NEIGHBORS|NEIGHBOR'S|FRIENDS|FRIEND'S|PARKED)\s+){0,3}"
|
|
19
|
+
VEH = r"(?:MVS?|CARS?|TRUCKS?|VEHICLES?|SUVS?|VANS?|MINIVAN|BUS|BUSES|AUTOS?|AUTOMOBILES?|PICKUPS?|SEMIS?|TAXI|CAB|JEEP|SEDAN|MOTOR\s+VEHICLES?|MOTORCYCLES?|TRACTOR[- ]TRAILER|18[- ]WHEELER|UBER|LYFT)"
|
|
20
|
+
MV = re.compile("|".join([
|
|
21
|
+
rf"\b(?:HIT|STRUCK|STRIKE[SD]?|STRIKING|HITTING|GRAZED|CLIPPED|SIDE\s*SWIPED|SIDESWIPED|BUMPED|KNOCKED\s+(?:DOWN|OVER|OFF)|RUN\s+OVER|RAN\s+OVER|REAR[- ]ENDED|T-?BONED|COLLID\w*\s+WITH|CRASH\w*\s+INTO)\s+(?:BY\s+)?(?:(?:THE|A)\s+)?(?:(?:SIDE|BACK|FRONT|REAR|HOOD|DOOR)\s+OF\s+)?{MOD}{VEH}\b",
|
|
22
|
+
rf"(?:\bINTO|\bWITH|\bAGAINST|\bONTO|\bOVER|\bAT|\bW/)\s+(?:THE\s+|A\s+)?(?:BACK|SIDE|FRONT|HOOD|DOOR|REAR)?\s*(?:OF\s+)?{MOD}{VEH}\b(?!\s+(?:SEAT|PARK|LOT|WASH))",
|
|
23
|
+
rf"\b{VEH}\s*(?:VS\.?|V\.?|VERSUS)\s",
|
|
24
|
+
rf"\s(?:VS\.?|V\.?|VERSUS)\s+{MOD}{VEH}\b",
|
|
25
|
+
rf"\b{VEH}\b[^.;]{{0,40}}\b(?:HIT|HITS|STRUCK|STRIKES|RAN\s+INTO|CRASHED\s+INTO|COLLIDED|PULLED\s+OUT|CUT\s+(?:HIM|HER|THEM)\s+OFF|RAN\s+(?:HIM|HER)\s+OVER|BACKED\s+INTO|TURNED\s+INTO|DOOR\s+OPENED|OPENED\s+(?:THE|A|ITS|HIS|HER)?\s*DOOR|SIDE\s*SWIPED|SIDESWIPED|CLIPP\w+|SWERVED\s+INTO|KNOCKED|RAN\s+OVER|REAR[- ]ENDED|T-?BONED|BACKED\s+(?:UP\s+)?INTO)",
|
|
26
|
+
rf"\b{VEH}\b[^.;]{{0,60}}\b(?:CRASHED|RAN|SLAMMED|RODE|WENT|HIT)\s+(?:HEAD[- ]ON\s+)?(?:INTO\s+)?(?:IT|HIM|HER|THEM)\b",
|
|
27
|
+
rf"(?<!CUT OFF )(?<!CUT HIM OFF )(?<!CUT HER OFF )(?<!AVOID )\bBY\s+{MOD}{VEH}\b",
|
|
28
|
+
rf"\b(?:MIRROR|DOOR|TIRE|WHEEL|HOOD|BUMPER|PEDAL|FENDER|WINDSHIELD|TAILGATE|TRAILER)\s+OF\s+{MOD}{VEH}\b",
|
|
29
|
+
rf"\b(?:HIT|STRUCK|BUMPED)\s+(?:HIS\s+|HER\s+)?(?:HEAD|FACE|ARM|LEG|KNEE|SHOULDER|HAND)\s+(?:ON|AGAINST)\s+{MOD}{VEH}\b",
|
|
30
|
+
r"\b(?:MVC|MVA|MV\s*C|AUTO\s*(?:VS|PED)|AUTO-PED|PED(?:ESTRIAN)?\s+STRUCK(?!\s+BY\s+(?:A\s+|AN\s+)?(?:BICYCL\w*|BIKE\w*|E-?BIKE|SCOOTER|E-?SCOOTER|SKATEBOARD|MOPED|CYCLIST|BICYCLIST))|DOORED|CAR\s+DOOR|MOTOR\s+VEHICLE\s+(?:COLLISION|ACCIDENT|CRASH)|BIKE\s+VS\.?\s+(?:CAR|AUTO|TRUCK)|BICYCLE\s+VS\.?\s+(?:CAR|AUTO|TRUCK))\b",
|
|
31
|
+
]))
|
|
32
|
+
EARLIER = re.compile(r"\b(?:\d+\s+(?:DAYS?|WEEKS?|MONTHS?|YEARS?|YRS?|WKS?)\s+AGO|LAST\s+(?:WEEK|MONTH|YEAR))\b[^.]{0,60}\b(?:MVC|MVA|CAR\s+ACCIDENT)")
|
|
33
|
+
|
|
34
|
+
LOADING = re.compile(rf"\b(?:LIFT\w*|LOAD\w*|PUT\w*|PLAC\w*|PUSH\w*|PULL\w*|CARRY\w*|GETTING|GET)\b[^.;]{{0,30}}\b(?:INTO|ONTO|IN|OUT\s+OF|OFF)\s+(?:THE\s+|A\s+|HIS\s+|HER\s+)?{VEH}\b")
|
|
35
|
+
|
|
36
|
+
def _clean(text):
|
|
37
|
+
t = (text or "").upper()
|
|
38
|
+
return re.sub(r"(?<=[A-Z])DX\b", " DX", t) # 'VEHICLEDX' -> 'VEHICLE DX'
|
|
39
|
+
|
|
40
|
+
def motor_vehicle(text):
|
|
41
|
+
t = _clean(text)
|
|
42
|
+
t = LOADING.sub(" ", t) # loading a bike into a car is not a crash
|
|
43
|
+
return bool(MV.search(t))
|
|
44
|
+
|
|
45
|
+
NO = re.compile("|".join([
|
|
46
|
+
r"\bNO\s+(?:BIKE\s+|BICYCLE\s+)?HELMETS?(?=\b|DX)", r"\bUN-?HELM\w*", r"\bNO\s+WEARING\b", r"\bNON-?HELMETT?ED\b", r"\bU[A-Z]{1,2}HELMETT?ED\b", r"\bNOT\s+HELMETT?ED\b", r"\bREFUSED\s+TO\s+WEAR\s+(?:A\s+|HIS\s+|HER\s+)?HELMET", r"\bW/\s?O\s+(?:A\s+)?HELMET", r"\bNO\s+(?:HELMET\s+)?(?:AND|OR|/)\s*(?:NO\s+)?LOC\b",
|
|
47
|
+
r"\bW/O\s+(?:A\s+|HIS\s+|HER\s+)?HELMET", r"\bWITHOUT\s+(?:A\s+|HIS\s+|HER\s+|WEARING\s+(?:A\s+)?)?HELMET",
|
|
48
|
+
r"\bNOT\s+(?:WEARING|WEAR|USING|HAVE|HAVING)\s+(?:ON\s+)?(?:A\s+|HIS\s+|HER\s+|ANY\s+)?HELMET",
|
|
49
|
+
r"\b(?:WAS|WERE)\s*N'?O?T\s+(?:WEARING|HELMETT?ED)", r"\bWASN'?T\s+(?:WEARING|HELMETT?ED)",
|
|
50
|
+
r"\bDIDN'?T\s+(?:HAVE|WEAR)\s+(?:ON\s+)?(?:A\s+)?HELMET", r"\bDID\s+NOT\s+(?:HAVE|WEAR)\s+(?:ON\s+)?(?:A\s+)?HELMET",
|
|
51
|
+
r"\bDENIES\s+(?:WEARING\s+)?(?:A\s+)?HELMET", r"(?<![A-Z0-9])-\s?HELMET", r"\bHELMET\s*[-:]\s*(?:NO|NEG|NONE|-)(?!\w)",
|
|
52
|
+
r"\bHELMET\s*(?:-)(?!\w)", r"\bHELMET\s+(?:NOT|NEVER)\s+(?:WORN|USED|ON)", r"\bNEG(?:ATIVE)?\s+(?:FOR\s+)?HELMET",
|
|
53
|
+
r"\bNO\s+(?:LOC\s*/\s*|LOC\s*,\s*|LOC\s+OR\s+|LOC\s+AND\s+)HELMET", r"\bNOR\s+HELMET",
|
|
54
|
+
]))
|
|
55
|
+
YES = re.compile("|".join([
|
|
56
|
+
r"\+\s?HELMET", r"\bHELMETT?ED\b", r"\bHELMET\s*\+", r"\b(?:WEARING|WEARIN|WERARING|WORE|WEARS|WITH|HAD\s+ON|HAD|W/)\s*(?:ON\s+)?(?:A\s+|HIS\s+|HER\s+|THEIR\s+|FULL[- ]FACE\s+|FULL\s+|HALF\s+|BIKE\s+|BICYCLE\s+|SCOOTER\s+|PROPER\s+|DIRT\s*BIKE\s+)*HELMETS?(?=\b|DX)",
|
|
57
|
+
r"\bHELMETS?\s+(?:ON|USE|USED|WORN|IN\s+PLACE|PRESENT|WAS\s+ON|INTACT|DAMAGED|BROKE|BROKEN|CRACKED|SPLIT)\b",
|
|
58
|
+
r"\b(?:UNDER|THROUGH|CRACKED|CRACKING|BROKE|BREAKING|BROKEN|DAMAGED|DENTED|SPLIT|HIT|HITTING|STRIKING|STRUCK)\s+(?:HIS\s+|HER\s+|THE\s+|THEIR\s+)?HELMET",
|
|
59
|
+
r"\bHELMET\s*[:=]\s*(?:YES|Y|POS|\+)(?!\w)", r"\+\s?[A-Z ]{0,25}(?:AND|&)\s+HELMET", r"\bHELMETT?(?:ED)?\s+(?:DRIVER|RIDER|BICYCLIST|CYCLIST|OPERATOR|BIKER)", r"\bHELMETS?\s+(?:WAS|IS|GOT|WERE)\s+(?:CRACKED|BROKEN|DAMAGED|SPLIT|DENTED|ON|WORN|SCRATCHED)", r"\bPOS(?:ITIVE)?\s+(?:FOR\s+)?HELMET",
|
|
60
|
+
]))
|
|
61
|
+
UNK = re.compile(r"(?:\bHELMET\s*(?:USE\s+)?(?:NS|N/S|UNK|UNKNOWN|NOT\s+(?:STATED|SPECIFIED|DOCUMENTED|REPORTED|NOTED|KNOWN))|UNKNOWN\s+(?:IF\s+|WHETHER\s+)?(?:\w+\s+)?HELMET|UNK\s+(?:IF\s+)?HELMET|\?\s*HELMET|HELMET\s*\?|HELMET\s+USE\s+UNK)")
|
|
62
|
+
|
|
63
|
+
def helmet(text):
|
|
64
|
+
t = _clean(text)
|
|
65
|
+
if "HELMET" not in t:
|
|
66
|
+
return "not mentioned"
|
|
67
|
+
if UNK.search(t):
|
|
68
|
+
return "not mentioned"
|
|
69
|
+
no = bool(NO.search(t))
|
|
70
|
+
yes = bool(YES.search(NO.sub(" ", t)))
|
|
71
|
+
if no and not yes: return "no"
|
|
72
|
+
if yes and not no: return "yes"
|
|
73
|
+
return "not mentioned"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""A small, dependency-light text classifier: e-bike vs moped, from NEISS injury narratives.
|
|
2
|
+
|
|
3
|
+
The model is TF-IDF (words and two-word phrases) + logistic regression, trained on NEISS
|
|
4
|
+
2024-2025 records where CPSC's codes separate e-bikes (5045) from other power-assisted
|
|
5
|
+
cycles (5046). It is stored as plain JSON (vocabulary, IDF weights, coefficients), so it
|
|
6
|
+
runs with numpy only and does not depend on a scikit-learn version. Retrain with
|
|
7
|
+
``scripts/train_classifier.py``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterable, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
_TOKEN = re.compile(r"(?u)\b\w\w+\b") # scikit-learn's default token pattern
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _features(text: str, ngram_max: int = 2) -> Counter:
|
|
26
|
+
tokens = _TOKEN.findall(text.lower())
|
|
27
|
+
feats: Counter = Counter(tokens)
|
|
28
|
+
for n in range(2, ngram_max + 1):
|
|
29
|
+
feats.update(" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
|
|
30
|
+
return feats
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EbikeMopedClassifier:
|
|
35
|
+
idf: dict[str, float]
|
|
36
|
+
coef: dict[str, float]
|
|
37
|
+
intercept: float
|
|
38
|
+
ngram_max: int = 2
|
|
39
|
+
sublinear_tf: bool = True
|
|
40
|
+
meta: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
# ---------- prediction ----------
|
|
43
|
+
def decision(self, text: str) -> float:
|
|
44
|
+
feats = _features(text, self.ngram_max)
|
|
45
|
+
weights = {}
|
|
46
|
+
for term, tf in feats.items():
|
|
47
|
+
idf = self.idf.get(term)
|
|
48
|
+
if idf is None:
|
|
49
|
+
continue
|
|
50
|
+
weights[term] = (1.0 + math.log(tf) if self.sublinear_tf else float(tf)) * idf
|
|
51
|
+
norm = math.sqrt(sum(w * w for w in weights.values())) or 1.0
|
|
52
|
+
return self.intercept + sum(w / norm * self.coef.get(t, 0.0) for t, w in weights.items())
|
|
53
|
+
|
|
54
|
+
def predict_proba(self, texts: Iterable[str]) -> np.ndarray:
|
|
55
|
+
"""Probability that each narrative describes an e-bike (vs a moped or similar)."""
|
|
56
|
+
z = np.array([self.decision(t) for t in texts], dtype=float)
|
|
57
|
+
return 1.0 / (1.0 + np.exp(-z))
|
|
58
|
+
|
|
59
|
+
# ---------- storage ----------
|
|
60
|
+
def to_json(self, path: Union[str, Path]) -> None:
|
|
61
|
+
terms = sorted(self.idf)
|
|
62
|
+
payload = {"meta": self.meta, "ngram_max": self.ngram_max, "sublinear_tf": self.sublinear_tf,
|
|
63
|
+
"intercept": round(self.intercept, 8),
|
|
64
|
+
"terms": terms,
|
|
65
|
+
"idf": [round(self.idf[t], 6) for t in terms],
|
|
66
|
+
"coef": [round(self.coef.get(t, 0.0), 6) for t in terms]}
|
|
67
|
+
Path(path).write_text(json.dumps(payload, separators=(",", ":")))
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_json(cls, path_or_text: Union[str, Path]) -> "EbikeMopedClassifier":
|
|
71
|
+
s = str(path_or_text)
|
|
72
|
+
text = s if s.lstrip().startswith("{") else Path(s).read_text()
|
|
73
|
+
p = json.loads(text)
|
|
74
|
+
idf = dict(zip(p["terms"], p["idf"]))
|
|
75
|
+
coef = {t: c for t, c in zip(p["terms"], p["coef"]) if c != 0.0}
|
|
76
|
+
return cls(idf=idf, coef=coef, intercept=p["intercept"], ngram_max=p.get("ngram_max", 2),
|
|
77
|
+
sublinear_tf=p.get("sublinear_tf", True), meta=p.get("meta", {}))
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def load_default(cls) -> "EbikeMopedClassifier":
|
|
81
|
+
text = resources.files("neissai").joinpath("model/ebike_moped_v1.json").read_text()
|
|
82
|
+
return cls.from_json(text)
|
|
83
|
+
|
|
84
|
+
# ---------- training (needs scikit-learn) ----------
|
|
85
|
+
@classmethod
|
|
86
|
+
def fit(cls, texts: list[str], is_ebike: list[int], min_df: int = 3, C: float = 4.0,
|
|
87
|
+
cv_folds: int = 5) -> "EbikeMopedClassifier":
|
|
88
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
89
|
+
from sklearn.linear_model import LogisticRegression
|
|
90
|
+
from sklearn.model_selection import cross_val_score
|
|
91
|
+
|
|
92
|
+
vec = TfidfVectorizer(ngram_range=(1, 2), min_df=min_df, sublinear_tf=True)
|
|
93
|
+
X = vec.fit_transform(texts)
|
|
94
|
+
lr = LogisticRegression(max_iter=2000, C=C, class_weight="balanced")
|
|
95
|
+
auc = float(cross_val_score(lr, X, is_ebike, cv=cv_folds, scoring="roc_auc").mean())
|
|
96
|
+
lr.fit(X, is_ebike)
|
|
97
|
+
vocab = vec.get_feature_names_out()
|
|
98
|
+
idf = dict(zip(vocab, vec.idf_.tolist()))
|
|
99
|
+
coef = dict(zip(vocab, lr.coef_[0].tolist()))
|
|
100
|
+
meta = {"n_train": len(texts), "n_ebike": int(sum(is_ebike)), "cv_auc": round(auc, 4),
|
|
101
|
+
"model": "TF-IDF (1-2 grams, sublinear) + logistic regression (balanced)"}
|
|
102
|
+
return cls(idf=idf, coef=coef, intercept=float(lr.intercept_[0]), meta=meta)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
neissai download --years 2019-2025 --data-dir data
|
|
4
|
+
neissai micromobility --years 2019-2025 --data-dir data --out-dir results
|
|
5
|
+
neissai classify "14YOM RIDING ELECTRIC BIKE FELL OFF"
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .classifier import EbikeMopedClassifier
|
|
17
|
+
from .devices import DEVICES, add_outcomes, label_devices
|
|
18
|
+
from .estimate import national_estimates, shares
|
|
19
|
+
from .io import download, read_neiss
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fmt(x: float) -> str:
|
|
23
|
+
return f"{x/1000:,.1f}k"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cmd_download(a: argparse.Namespace) -> int:
|
|
27
|
+
for p in download(a.years, a.data_dir):
|
|
28
|
+
print(f"saved {p}")
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_micromobility(a: argparse.Namespace) -> int:
|
|
33
|
+
out = Path(a.out_dir)
|
|
34
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
print(f"Reading NEISS files for {a.years} from {a.data_dir} ...")
|
|
36
|
+
df = read_neiss(a.data_dir, a.years)
|
|
37
|
+
print(f" {len(df):,} injury records")
|
|
38
|
+
print("Labelling devices (codes + narratives + text classifier) ...")
|
|
39
|
+
df = add_outcomes(label_devices(df, threshold=a.threshold))
|
|
40
|
+
mm = df["device"].isin(DEVICES)
|
|
41
|
+
print(f" {int(mm.sum()):,} micromobility records; "
|
|
42
|
+
f"{int((df['label_source'] == 'classifier').sum()):,} labelled by the classifier")
|
|
43
|
+
|
|
44
|
+
# Estimates use the full sample design (all records), counting only the selected ones.
|
|
45
|
+
by_year = national_estimates(df, by=["year", "device"], where=mm)
|
|
46
|
+
by_age = national_estimates(df, by=["year", "device", "age_group"], where=mm & df["age_group"].notna())
|
|
47
|
+
sev = pd.concat([shares(df, o, by=["device"], where=mm)
|
|
48
|
+
for o in ("hospitalized", "head_injury", "motor_vehicle", "helmet_no", "alcohol")])
|
|
49
|
+
df.loc[mm].to_csv(out / "micromobility_records.csv", index=False)
|
|
50
|
+
by_year.to_csv(out / "estimates_by_year_device.csv", index=False)
|
|
51
|
+
by_age.to_csv(out / "estimates_by_year_device_age.csv", index=False)
|
|
52
|
+
sev.to_csv(out / "shares_by_device.csv", index=False)
|
|
53
|
+
|
|
54
|
+
tab = by_year.pivot(index="device", columns="year", values="estimate").reindex(DEVICES).dropna(how="all")
|
|
55
|
+
print("\nEstimated US emergency department visits (thousands)")
|
|
56
|
+
print(tab.map(lambda v: "" if pd.isna(v) else _fmt(v)).to_string())
|
|
57
|
+
unstable = by_year[by_year["unstable"]]
|
|
58
|
+
if len(unstable):
|
|
59
|
+
print(f"\n{len(unstable)} estimate(s) are flagged unstable (CPSC rule); see the 'unstable' column.")
|
|
60
|
+
print(f"\nSaved results to {out.resolve()}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_classify(a: argparse.Namespace) -> int:
|
|
65
|
+
clf = EbikeMopedClassifier.load_default()
|
|
66
|
+
for text in a.text:
|
|
67
|
+
p = float(clf.predict_proba([text])[0])
|
|
68
|
+
print(f"{p:.3f} {'E-bike' if p >= 0.5 else 'Moped / other power-assisted cycle'} | {text}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
ap = argparse.ArgumentParser(prog="neissai", description="AI-assisted national injury estimates from NEISS narratives.")
|
|
74
|
+
ap.add_argument("--version", action="version", version=f"neissai {__version__}")
|
|
75
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
76
|
+
|
|
77
|
+
d = sub.add_parser("download", help="download CPSC NEISS annual files (best effort)")
|
|
78
|
+
d.add_argument("--years", default="2019-2025")
|
|
79
|
+
d.add_argument("--data-dir", default="data")
|
|
80
|
+
d.set_defaults(func=cmd_download)
|
|
81
|
+
|
|
82
|
+
m = sub.add_parser("micromobility", help="e-bike, e-scooter, bicycle and related injury estimates")
|
|
83
|
+
m.add_argument("--years", default="2019-2025")
|
|
84
|
+
m.add_argument("--data-dir", default="data")
|
|
85
|
+
m.add_argument("--out-dir", default="results")
|
|
86
|
+
m.add_argument("--threshold", type=float, default=0.5, help="classifier cut-off for 'E-bike' (default 0.5)")
|
|
87
|
+
m.set_defaults(func=cmd_micromobility)
|
|
88
|
+
|
|
89
|
+
c = sub.add_parser("classify", help="e-bike vs moped probability for narrative text")
|
|
90
|
+
c.add_argument("text", nargs="+")
|
|
91
|
+
c.set_defaults(func=cmd_classify)
|
|
92
|
+
|
|
93
|
+
a = ap.parse_args(argv)
|
|
94
|
+
return a.func(a)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__": # pragma: no cover
|
|
98
|
+
sys.exit(main())
|