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 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)).
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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())