Skip to content

Commit bdcfdf0

Browse files
fix(streaming): exact large integers and non-string column labels (#221)
- StreamingCleaner cast nullable Int*/UInt* columns to float64 before filling them with the running mean/median, so present values beyond 2**53 changed (2**53 + 1 became 2**53). Such columns now keep their integer dtype and get the rounded running statistic via _util.fill_na_exact; the report says so. Columns within +-2**53 keep the float64 behaviour. - StreamingCleaner.clean_batch and fd.clean_timeseries raised KeyError on non-string column labels (e.g. 0, 7): the imputer, drift detection and the time-series steps indexed the frame with str(label). The str name now only keys the running state and report entries; frame access uses the original label. Closes #208 Closes #209
1 parent e2e2d18 commit bdcfdf0

5 files changed

Lines changed: 66 additions & 19 deletions

File tree

‎src/freshdata/streaming/_cleaner.py‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
if TYPE_CHECKING:
2121
from ..context import ContextPolicy
2222

23+
from .._util import exceeds_float64_exact, fill_na_exact
2324
from ..cleaner import run_pipeline
2425
from ..config import CleanConfig, merge_options
2526
from ..engine.context import infer_role
@@ -117,7 +118,7 @@ def clean_batch(self, batch: Any) -> tuple[pd.DataFrame, CleanReport]:
117118
# In time-series mode the TS processor owns missing-value policy for its
118119
# numeric columns (short-gap interpolation, long-gap preservation), so the
119120
# generic statistical imputer must not pre-fill them.
120-
ts_skip = (set(self._ts.numeric_targets(cleaned, self._roles))
121+
ts_skip = ({str(c) for c in self._ts.numeric_targets(cleaned, self._roles)}
121122
if self._ts is not None else set())
122123
# Context-protected columns are never touched by the statistical imputer
123124
# either (the representation pass already byte-guards them); leave their
@@ -413,11 +414,13 @@ def _impute(self, df: pd.DataFrame, report: CleanReport,
413414
miss = int(df[col].isna().sum())
414415
if miss == 0:
415416
continue
416-
df = self._impute_column(df, name, miss, report)
417+
df = self._impute_column(df, col, name, miss, report)
417418
return df
418419

419-
def _impute_column(self, df: pd.DataFrame, col: str, miss: int,
420+
def _impute_column(self, df: pd.DataFrame, frame_col: object, col: str, miss: int,
420421
report: CleanReport) -> pd.DataFrame:
422+
"""Fill one column. *frame_col* is the frame's own label (it may be an int);
423+
*col* is its str name, which keys the running state and the report."""
421424
cs = self.state.columns[col]
422425
role, ratio = cs.role, cs.missing_ratio
423426
band = _band(ratio, self.config)
@@ -440,7 +443,7 @@ def _impute_column(self, df: pd.DataFrame, col: str, miss: int,
440443
return self._preserve(df, col, miss, report,
441444
rationale="datetime without a usable order; fill would "
442445
"invent timestamps", risk="medium")
443-
df[col] = df[col].ffill().bfill()
446+
df[frame_col] = df[frame_col].ffill().bfill()
444447
return self._record(df, report, col, miss, "forward/backward fill within batch",
445448
rationale="datetime column with monotonic order", confidence=0.8)
446449

@@ -457,24 +460,30 @@ def _impute_column(self, df: pd.DataFrame, col: str, miss: int,
457460
return self._preserve(df, col, miss, report,
458461
rationale="no running statistic available yet",
459462
risk="medium", confidence=0.5)
460-
df[col] = (df[col].astype("float64") if df[col].dtype.kind in "iu"
461-
else df[col]).fillna(value)
462-
return self._record(df, report, col, miss, f"{label} ({value:.6g})",
463+
s = df[frame_col]
464+
note = ""
465+
if exceeds_float64_exact(s):
466+
# A float64 cast would change present values beyond 2**53: keep the
467+
# integer dtype and fill with the (rounded) running statistic.
468+
df[frame_col], note = fill_na_exact(s, value)
469+
else:
470+
df[frame_col] = (s.astype("float64") if s.dtype.kind in "iu" else s).fillna(value)
471+
return self._record(df, report, col, miss, f"{label} ({value:.6g}{note})",
463472
rationale=rationale, confidence=0.8 if band == "low" else 0.7)
464473

465474
# categorical / boolean
466475
mode, mode_ratio = cs.mode(), cs.mode_ratio()
467476
threshold = 0.5 if band == "low" else 0.6
468477
if mode is not None and mode_ratio is not None and mode_ratio >= threshold:
469-
df[col] = df[col].fillna(mode)
478+
df[frame_col] = df[frame_col].fillna(mode)
470479
return self._record(df, report, col, miss, f"running mode ({mode!r})",
471480
rationale=f"dominant category ({100 * mode_ratio:.0f}% of seen)",
472481
confidence=0.8 if band == "low" else 0.7)
473482
sentinel = "Unknown" if band == "low" else "Missing"
474-
s = df[col]
483+
s = df[frame_col]
475484
if isinstance(s.dtype, pd.CategoricalDtype) and sentinel not in s.cat.categories:
476485
s = s.cat.add_categories([sentinel])
477-
df[col] = s.fillna(sentinel)
486+
df[frame_col] = s.fillna(sentinel)
478487
return self._record(df, report, col, miss, f'sentinel "{sentinel}"',
479488
rationale="no dominant category; sentinel keeps the gap visible",
480489
confidence=0.7, risk="low" if band == "low" else "medium")

‎src/freshdata/streaming/_drift.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ def detect_drift(
4141
if not state.schema_baseline:
4242
return findings
4343

44-
batch_cols = [str(c) for c in df.columns]
44+
# Baseline and running state are keyed by str label; the frame keeps its own
45+
# labels (e.g. ints), so index it through this map.
46+
labels = {str(c): c for c in df.columns}
47+
batch_cols = list(labels)
4548
baseline = set(state.schema_baseline)
4649
seen = set(batch_cols)
4750

@@ -61,7 +64,7 @@ def detect_drift(
6164
for col in batch_cols:
6265
if col not in baseline:
6366
continue
64-
s = df[col]
67+
s = df[labels[col]]
6568
baseline_dtype = state.baseline_dtypes.get(col)
6669
if baseline_dtype is not None and str(s.dtype) != baseline_dtype:
6770
findings.append(DriftFinding(

‎src/freshdata/streaming/_timeseries.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def process(self, df: pd.DataFrame, report: CleanReport, *,
243243

244244
roles = roles or self._infer_roles(df)
245245
numeric_cols = self.numeric_targets(df, roles)
246-
self.last_numeric_cols = list(numeric_cols)
246+
self.last_numeric_cols = [str(c) for c in numeric_cols]
247247

248248
# 3. Short-gap interpolation, then 4. seasonal imputation of what's left.
249249
df = self._interpolate(df, numeric_cols, report)
@@ -415,11 +415,11 @@ def fill_group(g: pd.DataFrame, col: str = col) -> pd.DataFrame:
415415
report.add("timeseries_interpolation",
416416
f"interpolated {filled_total} short-gap value(s) "
417417
f"(<= {cfg.max_interpolation_gap} step gap, method={method})",
418-
column=col, count=filled_total, risk="low",
418+
column=str(col), count=filled_total, risk="low",
419419
confidence=0.8 if method in ("time", "linear") else 0.7,
420420
rationale="short consecutive gap in an ordered series",
421421
model_id=f"interp_{method}")
422-
report.columns_imputed.append(col)
422+
report.columns_imputed.append(str(col))
423423
return df
424424

425425
@staticmethod
@@ -486,10 +486,10 @@ def fill_group(g: pd.DataFrame, col: str = col) -> pd.DataFrame:
486486
rationale = (f"{seasonal_filled} from matching {cfg.seasonal_period} season, "
487487
f"{fallback_filled} from rolling/global median fallback")
488488
report.add("seasonal_imputation",
489-
f"seasonally imputed {total} value(s)", column=col, count=total,
489+
f"seasonally imputed {total} value(s)", column=str(col), count=total,
490490
risk="medium", confidence=conf, rationale=rationale,
491491
model_id="seasonal_median")
492-
report.columns_imputed.append(col)
492+
report.columns_imputed.append(str(col))
493493
return df
494494

495495
# -- step 6: windowed anomaly detection ------------------------------------
@@ -535,7 +535,7 @@ def score_group(g: pd.DataFrame, col: str = col, flags: pd.Series = flags,
535535
action_note = "flagged and capped"
536536
report.add("windowed_anomaly",
537537
f"{action_note} {n_flag} windowed anomaly(ies) (method={method}, "
538-
f"window={win})", column=col, count=n_flag, risk="medium",
538+
f"window={win})", column=str(col), count=n_flag, risk="medium",
539539
confidence=0.7,
540540
rationale=f"rolling {method} score beyond {thr}",
541541
model_id=f"anomaly_{method}")
@@ -595,7 +595,7 @@ def numeric_targets(self, df: pd.DataFrame,
595595
continue
596596
if not pd.api.types.is_numeric_dtype(df[c]):
597597
continue
598-
cols.append(name)
598+
cols.append(c) # the frame's own label (may be an int), not str(c)
599599
return cols
600600

601601

‎tests/test_streaming_cleaner.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,3 +238,27 @@ def test_global_duplicate_window_dedup():
238238
assert len(cleaned) == 0 # every row is a cross-batch duplicate
239239
assert report.duplicates_removed == 5
240240
assert any(a.step == "duplicates" for a in report)
241+
242+
243+
def test_integer_column_labels_are_imputed_without_keyerror():
244+
cleaner = fd.StreamingCleaner(warmup_batches=0, verbose=False)
245+
batch = pd.DataFrame({0: [1.0, None, 3.0, 4.0], 1: [1.0, 2.0, 3.0, 4.0]})
246+
cleaned, report = cleaner.clean_batch(batch)
247+
assert list(cleaned.columns) == [0, 1]
248+
assert any(a.step == "missing" and a.column == "0" for a in report)
249+
# The second batch runs drift detection against the str-keyed baseline.
250+
cleaned2, report2 = cleaner.clean_batch(batch.copy())
251+
assert list(cleaned2.columns) == [0, 1]
252+
assert any(a.step == "missing" and a.column == "0" for a in report2)
253+
254+
255+
def test_numeric_fill_keeps_int64_beyond_2_53_exact():
256+
big = 2**53 + 1
257+
df = pd.DataFrame({"x": pd.array([big, None, 2**53 + 3, big], dtype="Int64"),
258+
"y": [1.0, 2.0, 3.0, 4.0]})
259+
out, report = fd.StreamingCleaner(warmup_batches=0, verbose=False).clean_batch(df)
260+
assert str(out["x"].dtype) == "Int64"
261+
assert [int(out["x"][i]) for i in (0, 2, 3)] == [big, 2**53 + 3, big]
262+
assert out["x"].isna().sum() == 0
263+
notes = [a.description for a in report if a.step == "missing" and a.column == "x"]
264+
assert notes and "2**53" in notes[0]

‎tests/test_streaming_timeseries.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,14 @@ def test_no_anomalies_still_emits_flag_column():
336336
df, timestamp_column="t", anomaly_window_size=10, anomaly_method="rolling_zscore")
337337
assert "x_anomaly" in out.columns
338338
assert not out["x_anomaly"].any()
339+
340+
341+
def test_integer_column_labels_in_timeseries_mode():
342+
df = pd.DataFrame({"t": pd.date_range("2024", periods=4, freq="h"),
343+
7: ["a", None, "a", "a"],
344+
8: [1.0, np.nan, 3.0, 4.0]})
345+
out, report = fd.clean_timeseries(df, timestamp_column="t", max_interpolation_gap=1,
346+
return_report=True)
347+
assert 7 in out.columns and 8 in out.columns
348+
assert out[8].tolist() == [1.0, 2.0, 3.0, 4.0]
349+
assert any(a.step == "timeseries_interpolation" and a.column == "8" for a in report)

0 commit comments

Comments
 (0)