Commit ·
f28898b
1
Parent(s): 9c70930
[NOTICKET] fix(trend,forecast): a period with no rows stays empty (P0-8)
Browse files- resample().agg("sum") turned a month with no rows into 0: the trend read it as a
collapse and the forecast fitted it as a real value
- New temporal._resample(): sum uses min_count=1, so an empty period is None; count
keeps 0 (zero events is the true count); other aggs were already None
- analyze_forecast uses the same helper, so the empty month is interpolated for the fit
- Example (April empty): trend R^2 0.06 -> 0.90, forecast no longer dips after "April 0"
- Chart already draws None as a gap; the report table shows it as a dash
- Reported by Rifqi (audit P0-8, decision D4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/tools/analytics/forecast.py
CHANGED
|
@@ -34,6 +34,7 @@ from src.tools.analytics.temporal import (
|
|
| 34 |
_clean,
|
| 35 |
_parse_date_column,
|
| 36 |
_period_label,
|
|
|
|
| 37 |
)
|
| 38 |
from src.tools.ml.executor import run_fit
|
| 39 |
from src.tools.spec_builder import build_description
|
|
@@ -221,7 +222,7 @@ async def analyze_forecast(
|
|
| 221 |
dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
|
| 222 |
values = pd.Series(df[value_column].to_numpy(), name="_value")
|
| 223 |
frame = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
|
| 224 |
-
resampled = frame["_value"]
|
| 225 |
|
| 226 |
observed = resampled.dropna()
|
| 227 |
if observed.shape[0] < _MIN_POINTS:
|
|
|
|
| 34 |
_clean,
|
| 35 |
_parse_date_column,
|
| 36 |
_period_label,
|
| 37 |
+
_resample,
|
| 38 |
)
|
| 39 |
from src.tools.ml.executor import run_fit
|
| 40 |
from src.tools.spec_builder import build_description
|
|
|
|
| 222 |
dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
|
| 223 |
values = pd.Series(df[value_column].to_numpy(), name="_value")
|
| 224 |
frame = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
|
| 225 |
+
resampled = _resample(frame["_value"], freq, agg).astype(float)
|
| 226 |
|
| 227 |
observed = resampled.dropna()
|
| 228 |
if observed.shape[0] < _MIN_POINTS:
|
src/tools/analytics/temporal.py
CHANGED
|
@@ -128,6 +128,19 @@ def _parse_date_column(df: pd.DataFrame, date_column: str) -> pd.Series:
|
|
| 128 |
)
|
| 129 |
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
def _period_label(ts: pd.Timestamp, freq: str) -> str:
|
| 132 |
"""Human-readable period label keyed off the friendly frequency name."""
|
| 133 |
if freq == "month":
|
|
@@ -268,7 +281,7 @@ def analyze_trend(
|
|
| 268 |
dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
|
| 269 |
values = pd.Series(df[value_column].to_numpy(), name="_value")
|
| 270 |
s = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
|
| 271 |
-
resampled = s["_value"]
|
| 272 |
|
| 273 |
points = [
|
| 274 |
{"period": _period_label(ts, freq), "value": _clean(val)}
|
|
|
|
| 128 |
)
|
| 129 |
|
| 130 |
|
| 131 |
+
def _resample(series: pd.Series, freq: str, agg: str) -> pd.Series:
|
| 132 |
+
"""Aggregate a datetime-indexed series per period; a period with no rows stays empty.
|
| 133 |
+
|
| 134 |
+
Plain `resample().agg("sum")` turns a month with no rows into 0, which reads as a
|
| 135 |
+
collapse in the trend and is fitted as a real value by the forecast. `count` keeps
|
| 136 |
+
its 0 — zero events in a month is the true count.
|
| 137 |
+
"""
|
| 138 |
+
resampler = series.sort_index().resample(FREQ_MAP[freq])
|
| 139 |
+
if agg == "sum":
|
| 140 |
+
return resampler.sum(min_count=1)
|
| 141 |
+
return resampler.agg(agg)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def _period_label(ts: pd.Timestamp, freq: str) -> str:
|
| 145 |
"""Human-readable period label keyed off the friendly frequency name."""
|
| 146 |
if freq == "month":
|
|
|
|
| 281 |
dates = pd.Series(_parse_date_column(df, date_column).to_numpy(), name="_date")
|
| 282 |
values = pd.Series(df[value_column].to_numpy(), name="_value")
|
| 283 |
s = pd.concat([dates, values], axis=1).dropna(subset=["_date"]).set_index("_date")
|
| 284 |
+
resampled = _resample(s["_value"], freq, agg)
|
| 285 |
|
| 286 |
points = [
|
| 287 |
{"period": _period_label(ts, freq), "value": _clean(val)}
|