Skip to content

Commit a463d17

Browse files
rootcoder007claude
andcommitted
Accept real pandas frames in the Mandela, OTIS and SIU estimators; doctor points at download-bootstrap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVZR4QjrTXCv55j4qdo6E2
1 parent e4d23a9 commit a463d17

7 files changed

Lines changed: 81 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ morie is a Python (and R) package — once Python is present it is `pip install
2121

2222
- **Windows** — install Python from [python.org](https://www.python.org/downloads/) (on the first screen tick **Add python.exe to PATH**), then `pip install morie`. Full walkthrough: [Windows](#recommended--windows) below. Windows has no `curl`/`bash`, so the one-liner does not apply there.
2323
- **macOS / Linux** — the one-liner below sets up everything. It needs `curl` and `bash`, which macOS has built in and most Linux ships.
24-
- **Already have Python ≥3.10** — just `pip install morie`.
24+
- **Already have Python ≥3.10** — just `pip install morie`. The estimators take a pandas DataFrame, a CSV path, or a dict of columns; pandas is optional (morie ships its own frame core).
2525

2626
### For terminal users — one-liner (Linux / macOS / WSL)
2727

src/morie/doctor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def _check_datasets() -> tuple[bool, str]:
111111

112112
db_path = morie_db()
113113
if not db_path.exists():
114-
return False, "morie.db not found -- reinstall morie"
114+
return False, "morie.db not found -- it is fetched, not shipped: run `morie download-bootstrap`"
115115
size_mb = db_path.stat().st_size // (1024 * 1024)
116116
ds = list_datasets()
117117
cached = [d for d in ds if d["cached"]]
@@ -353,7 +353,7 @@ def _heal(results: dict[str, Any]) -> bool:
353353
elif label == "morie version":
354354
print(" [hint] run `morie update` to upgrade morie itself.")
355355
elif label == "Built-in datasets":
356-
print(" [hint] reinstall to restore the built-in DB: pip install --force-reinstall morie")
356+
print(" [hint] the built-in DB is downloaded, not shipped: run `morie download-bootstrap`.")
357357
elif label == "R (Rscript)":
358358
print(" [hint] install R from https://www.r-project.org/ (optional -- only the R bridge needs it).")
359359
else:

src/morie/fn/_frame_core.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import csv as _csv
1818
import datetime as _dt
1919
import math as _math
20+
import os
2021

2122
from . import _array_core as _ac
2223

@@ -280,7 +281,7 @@ def _binop(self, other, fn):
280281
if isinstance(other, Series):
281282
other = other._data
282283
# A native array (marr/oarr) is a sequence, not a scalar: without this
283-
# it was broadcast whole against each element, so
284+
# it was broadcast whole against each element, so
284285
# produced a column OF ARRAYS instead of an elementwise sum.
285286
if not isinstance(other, (str, bytes)) and hasattr(other, "__len__") and hasattr(other, "__iter__") and not isinstance(other, dict):
286287
other = list(other)
@@ -1551,9 +1552,7 @@ def sql_type(values):
15511552
for v in values:
15521553
if v is None or _isnan(v):
15531554
continue
1554-
if isinstance(v, bool):
1555-
seen.add("INTEGER")
1556-
elif isinstance(v, int):
1555+
if isinstance(v, bool) or isinstance(v, int):
15571556
seen.add("INTEGER")
15581557
elif isinstance(v, float):
15591558
seen.add("REAL")
@@ -2917,7 +2916,6 @@ def close(self):
29172916

29182917
class ExcelFile:
29192918
def __init__(self, path):
2920-
import xml.etree.ElementTree as ET
29212919
import zipfile
29222920
self._path = path
29232921
zf = zipfile.ZipFile(path)
@@ -3017,11 +3015,46 @@ def _assert_series_equal(left, right, **kw):
30173015
DataFrame({"v": list(right)}), **kw)
30183016

30193017

3020-
class _TestingNamespace(object):
3018+
class _TestingNamespace:
30213019
"""Mirrors `pandas.testing`."""
30223020

30233021
assert_frame_equal = staticmethod(_assert_frame_equal)
30243022
assert_series_equal = staticmethod(_assert_series_equal)
30253023

30263024

30273025
testing = _TestingNamespace()
3026+
3027+
3028+
def coerce_frame(data, what: str = "data") -> DataFrame:
3029+
"""Return a native DataFrame for whatever a caller hands an estimator.
3030+
3031+
Accepts a native DataFrame (returned as is), a pandas DataFrame or any
3032+
object exposing ``columns`` and column access, a path to a CSV file,
3033+
or a sequence of row mappings. The estimators index with native masks;
3034+
a pandas frame reaching them directly treats those masks as labels and
3035+
fails with a KeyError (found on the flagship's own bundled sample,
3036+
2026-09-18), so every public entry point coerces here first.
3037+
"""
3038+
if isinstance(data, DataFrame):
3039+
return data
3040+
if isinstance(data, (str, os.PathLike)):
3041+
path = os.fspath(data)
3042+
if not os.path.exists(path):
3043+
raise FileNotFoundError(f"{what}: no such file {path!r}")
3044+
return read_csv(path)
3045+
if hasattr(data, "columns") and hasattr(data, "__getitem__"):
3046+
cols = list(data.columns)
3047+
out = {}
3048+
for c in cols:
3049+
v = data[c]
3050+
out[c] = list(v.tolist()) if hasattr(v, "tolist") else list(v)
3051+
return DataFrame(out)
3052+
if isinstance(data, dict):
3053+
return DataFrame(data)
3054+
if isinstance(data, (list, tuple)):
3055+
rows = list(data)
3056+
if rows and all(isinstance(r, dict) for r in rows):
3057+
cols = list(rows[0].keys())
3058+
return DataFrame({c: [r.get(c) for r in rows] for c in cols})
3059+
raise TypeError(f"{what} must be a DataFrame (native or pandas), a CSV path, "
3060+
f"a dict of columns or a list of row dicts, got {type(data).__name__}")

src/morie/mrm_mandela_spectrum.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def mrm_otis_mandela_spectrum(
104104
... columns="contact_proxy",
105105
... values="pct")
106106
"""
107-
df = data.copy()
107+
df = pd.coerce_frame(data).copy()
108108
dur = pd.to_numeric(df[duration_col], errors="coerce")
109109
df["_dur"] = dur
110110
df["_long"] = (dur > threshold_days).fillna(False)
@@ -158,7 +158,8 @@ def _eligible_mask(proxy: str) -> pd.Series:
158158
ids_m = df.loc[elig].groupby(id_col)["_dur"].sum()
159159
cum_long = cum > threshold_days
160160
# restrict cum_long to ids that had alert-proxy-active placements
161-
cum_long_proxy = cum_long.index.isin(ids_m.index) & cum_long.values
161+
in_proxy = cum_long.index.isin(ids_m.index)
162+
cum_long_proxy = [bool(a) and bool(b) for a, b in zip(in_proxy, list(cum_long.values))]
162163
n_d = int(cum.size)
163164
if proxy == "none":
164165
n_m = int(cum_long.sum())

src/morie/mrm_otis.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def mrm_otis_placement_concentration(
9393
n_individuals, n_placements, mean_per_individual, gini,
9494
hill_alpha, top_pct_share.
9595
"""
96-
df = data.copy()
96+
df = pd.coerce_frame(data).copy()
9797
if gender_col and gender_keep is not None:
9898
df = df[df[gender_col].isin(list(gender_keep))]
9999
df["_midpt"] = df[band_col].map(_band_to_midpoint)

src/morie/mrm_siu.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def mrm_siu_case_to_decision_km(
7171
min_n: int = 5,
7272
) -> SIUCaseDecisionResult:
7373
"""KM-style time-from-incident-to-Director's-decision summary."""
74-
df = data.copy()
74+
df = pd.coerce_frame(data).copy()
7575
inc = pd.to_datetime(df[incident_col], errors="coerce")
7676
dec = pd.to_datetime(df[decision_col], errors="coerce")
7777
svc = df[service_col].astype(str)
@@ -116,7 +116,7 @@ def mrm_siu_per_service_rate(
116116
stratify_col: str | None = None,
117117
) -> pd.DataFrame:
118118
"""Per-police-service case counts by year (and optional stratum)."""
119-
df = data.copy()
119+
df = pd.coerce_frame(data).copy()
120120
df["_year"] = pd.to_datetime(df[incident_col], errors="coerce").dt.year
121121
svc = df[service_col].astype(str)
122122
df = df[df["_year"].notna() & (svc.str.len() > 0) & (svc != "nan")]
@@ -139,7 +139,7 @@ def mrm_siu_outcome_classifier(
139139
service_col: str = "police_service",
140140
) -> pd.DataFrame:
141141
"""Tabulate SIU Director's-decision outcomes by service."""
142-
df = data.copy()
142+
df = pd.coerce_frame(data).copy()
143143
if outcome_col not in df.columns:
144144
for alt in [
145145
"director_decision",

tests/test_mrm_mandela_spectrum.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import pytest
2+
3+
4+
def test_flagship_accepts_pandas_frames_csv_paths_and_rows(tmp_path):
5+
"""The Mandela spectrum crashed on a real pandas frame (KeyError on a
6+
native boolean mask) and on a CSV path; every entry point now coerces."""
7+
import os
8+
9+
import morie
10+
from morie.fn import _frame_core as fc
11+
from morie.mrm_mandela_spectrum import mrm_otis_mandela_spectrum as f
12+
p = os.path.join(os.path.dirname(morie.__file__), "data/samples/otis_b01_sample.csv")
13+
native = f(p)
14+
assert len(native) > 0
15+
rows = fc.read_csv(p)
16+
class FakePandas: # duck-typed like pandas: .columns and column access with .tolist()
17+
def __init__(self, frame):
18+
self.columns = list(frame.columns); self._f = frame
19+
def __getitem__(self, c):
20+
class Col:
21+
def __init__(self, v): self.v = list(v)
22+
def tolist(self): return self.v
23+
return Col(self._f[c])
24+
fake = f(FakePandas(rows))
25+
assert fake.to_dict("records") == native.to_dict("records")
26+
pd = pytest.importorskip("pandas")
27+
real = f(pd.read_csv(p))
28+
assert real.to_dict("records") == native.to_dict("records")
29+
with pytest.raises(FileNotFoundError):
30+
f(str(tmp_path / "missing.csv"))
31+
with pytest.raises(TypeError):
32+
f(12345)

0 commit comments

Comments
 (0)