5a4f82cf76
- python/functions/bigquery/bq_auth.md - python/functions/bigquery/bq_load_from_file.md - python/functions/bigquery/bq_load_from_gcs.md - python/functions/bigquery/client.py - python/functions/bigquery/queries.py - python/functions/datascience/__init__.py - python/functions/datascience/decode_qr_image.py - python/functions/datascience/load_bq_table_to_duckdb.md - python/functions/datascience/load_bq_table_to_duckdb.py - python/functions/pipelines/profile_bq_table.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
5.4 KiB
Python
127 lines
5.4 KiB
Python
"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia.
|
|
|
|
Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro
|
|
de una o varias series temporales diarias combinando dos senales:
|
|
|
|
1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el
|
|
MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series
|
|
intermitentes donde "sin fila" significa "sin venta").
|
|
2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente
|
|
respecto al periodo inmediatamente anterior (razon de sumas), acotado a un
|
|
rango para no amplificar ruido.
|
|
|
|
Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria
|
|
CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la
|
|
demanda (los sabados venden distinto que los martes). Solo usa stdlib
|
|
(datetime, statistics).
|
|
"""
|
|
|
|
from datetime import date, datetime, timedelta
|
|
from statistics import median
|
|
|
|
|
|
def _to_date(value: str) -> date:
|
|
"""Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date."""
|
|
if isinstance(value, date) and not isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
|
|
|
|
|
def forecast_seasonal_median(
|
|
history: list[dict],
|
|
horizon_dates: list[str],
|
|
as_of: str,
|
|
dow_weeks: int = 8,
|
|
trend_recent_weeks: int = 4,
|
|
trend_clip: tuple = (0.5, 2.0),
|
|
) -> list[dict]:
|
|
"""Predice el valor de cada serie para cada fecha del horizonte.
|
|
|
|
Para cada serie presente en `history` y cada fecha objetivo del horizonte:
|
|
|
|
1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el
|
|
MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las
|
|
fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de
|
|
semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la
|
|
historia cuenta como 0 (series intermitentes).
|
|
2. Factor de tendencia por serie = suma de los valores de las ultimas
|
|
`trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la
|
|
suma de las `trend_recent_weeks` semanas anteriores a esas. Si el
|
|
denominador es 0 el factor es 1.0. Se acota a `trend_clip`.
|
|
3. y_pred = max(0.0, base * factor).
|
|
|
|
Args:
|
|
history: observaciones {"series_id": str, "date": "YYYY-MM-DD",
|
|
"value": float}. Filas duplicadas (misma serie y fecha) se suman. Los
|
|
dias sin fila dentro de las ventanas se tratan como valor 0.
|
|
horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD').
|
|
as_of: fecha de corte (ultimo dia de historia utilizable, inclusive).
|
|
dow_weeks: numero de fechas del mismo dia de semana a promediar para la
|
|
base estacional. Default 8.
|
|
trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de
|
|
tendencia (reciente y anterior). Default 4.
|
|
trend_clip: (min, max) al que se acota el factor de tendencia. Default
|
|
(0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas
|
|
que duplicarse por tendencia.
|
|
|
|
Returns:
|
|
Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por
|
|
cada serie presente en `history` y cada fecha del horizonte. Ordenada por
|
|
series_id (asc) y luego por el orden de `horizon_dates`.
|
|
"""
|
|
as_of_d = _to_date(as_of)
|
|
lo_clip, hi_clip = trend_clip
|
|
|
|
# Mapa (series_id, date) -> valor acumulado + conjunto de series presentes.
|
|
values: dict[tuple[str, date], float] = {}
|
|
series_ids: set[str] = set()
|
|
for obs in history:
|
|
sid = obs["series_id"]
|
|
d = _to_date(obs["date"])
|
|
v = float(obs.get("value", 0.0) or 0.0)
|
|
series_ids.add(sid)
|
|
values[(sid, d)] = values.get((sid, d), 0.0) + v
|
|
|
|
# Ventanas de tendencia (en dias) relativas a as_of.
|
|
span = 7 * trend_recent_weeks
|
|
recent_lo = as_of_d - timedelta(days=span) # reciente: recent_lo < d <= as_of
|
|
prior_lo = as_of_d - timedelta(days=2 * span) # anterior: prior_lo < d <= recent_lo
|
|
|
|
# Factor de tendencia por serie (una sola vez por serie, no depende del horizonte).
|
|
trend_factor: dict[str, float] = {}
|
|
for sid in series_ids:
|
|
recent_sum = 0.0
|
|
prior_sum = 0.0
|
|
for (s, d), v in values.items():
|
|
if s != sid:
|
|
continue
|
|
if recent_lo < d <= as_of_d:
|
|
recent_sum += v
|
|
elif prior_lo < d <= recent_lo:
|
|
prior_sum += v
|
|
if prior_sum == 0.0:
|
|
factor = 1.0
|
|
else:
|
|
factor = recent_sum / prior_sum
|
|
trend_factor[sid] = min(hi_clip, max(lo_clip, factor))
|
|
|
|
horizon = [_to_date(h) for h in horizon_dates]
|
|
out: list[dict] = []
|
|
for sid in sorted(series_ids):
|
|
factor = trend_factor[sid]
|
|
for h_str, h_d in zip(horizon_dates, horizon):
|
|
# Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo.
|
|
back = (as_of_d.weekday() - h_d.weekday()) % 7
|
|
anchor = as_of_d - timedelta(days=back)
|
|
dow_values = [
|
|
values.get((sid, anchor - timedelta(days=7 * i)), 0.0)
|
|
for i in range(dow_weeks)
|
|
]
|
|
base = median(dow_values)
|
|
y_pred = max(0.0, base * factor)
|
|
out.append({"series_id": sid, "date": h_str, "y_pred": y_pred})
|
|
|
|
return out
|