Files
fn_registry/python/functions/datascience/forecast_seasonal_median.py
T
egutierrez 5a4f82cf76 chore: auto-commit (26 archivos)
- 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>
2026-07-02 19:00:13 +02:00

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