1. EXPLICACION DEL CASO:¶
Para todo el proyecto le damos un nombre ficticio a la empresa, FerMar S.L.
La empresa FerMar S.L. dispone de activos immobiliarios en distintos países que operan en el sector del juego a través de actividades de Bingos, Casinos, Salones Recreativos y Locales de Apuestas, así como de actividades que sirven de soporte a los primeros como pueden ser oficinas corporativas, almacenes y naves logísticas.
Como consultores del proyecto y expertos en el area del Facility Management, Data Science y Servicios Web, nos han contratado para optimizar el sistema actual de previsiones de gasto del departamento de Facilty Management.
Actualmente dedican una gran cantidad de horas de gerentes y analistas de cada gerencia de Pais durante los meses de julio, agosto y septiembre a preparar los presupuestos de OPEX para el año siguiente.
TABLA 1: ESTIMACIÓN DE LAS HORAS DEDICADAS A LA CONFECCIÓN DEL PRESUPUESTO DE OPEX, PO OPEX: Listado paises - Horas Gerencia - Horas Analista
La metodología que utilizan actualmente es tradicional, basada en utilizar la fuente de datos del año anterior para estimar el próximo año a traves de la herramienta Excel que luego cargan a otra de seguimiento presupuestario llamada BPC.
Se genera un modelo futuro de hechos basados en el del año anterior que se ajusta según el IPC previsto. Luego se realiza un segundo ajuste en función de una serie de acciones concretas que se desean implementar y que pueden afectar trasversalmente a todos los espacios de una en concreto o varias de sus 4 cuentas contables en que se desglosa el OPEX, a un grupo de ellos o incluso a alguno individualmente. Este segundo ajuste tiene una fecha inicio según esté prevista. El alcance temporal de las previsiones es por anualidades.
La mayoría de las previsiones resultantes del PO OPEX generado con esta metodología dista mucho de la realidad ejecutada durante el año si lo miramos a nivel immueble por immueble, ahora bien, cuando se contrasta por cuentas contables o a nivel grupo de immuebles, las diferencias se reducen e incluso acaban coincidiendo (propósito y objetivo de la organización), sobre todo por las acciones correctivas que durante el año corrigen las desviaciones.
Nos han solicitado que les estudiemos si existe alguna otra metodología más actual y basada en Machine Learning o Deep Learning que les permita obtener estimacions de su OPEX futuro de una manera más precisa y ajustada immueble por immueble y si les podemos dotar de una herramienta web que les agilice el sistema de presupuestación actual.
El problema es el tiempo que disponemos para entregar el proyecto, fecha límite 30 de septiembre. El motivo de esta FerMar SL necesita poder contrastar nuestra propuesta con la que ellos han desarrollado con el fin de validar si avanzar o no en esta nueva metodología. La entrega del PO 2026 es el próximo 1 de octubre, así que les hemos propuesto entregar un producto no definitivo pero que si les permita tomar decisión sobre si seguir o no con su desarrollo para mejorar la precisión de las estimaciones.
TABLA 2 - OFERTA DE LOS SERVICIOS DE CONSULTORIA
LISTA DE REQUERIMIENTOS¶
Pendiente cumplimentar
ESTRATEGIA PARA ABORDAR EL PROYECTO¶
Para poder abordar este proyecto, hemos identificado los siguientes bloques en los que deberemos trabajar e implementar:
BLOQUES DEL PROYECTO
BLOQUE 1 - ANALISIS METODOLOGÍA Y OUTPUT ACTUAL PARA CONFECCIONAR EL PO 2026.
BLOQUE 2 - MODELIZACIÓN EN MACHINE LEARNING (MENOS REQUERIMIENTO COMPUTACIONALES Y PERMITE LA COMPRENSION DE LOS PATRONES) O SERIES TEMPORALES O MODELO HIBRIDO.
BLOQUE 3 - CONFECCIONAR UN SERVICIO WEB CON LA CAPACIDAD DE INCORPORAR EL MODELO PREDICTIVO OBTENIDO EN EL BLOQUE 2 PARA ENTREGAR EL PO 2026.
BLOQUE 4 - DOCUMENTAR EL PROYECTO EN LA PROPIA WEB.
ENTREGABLES
- APP WEB DE PREVISIONES Y DOCUMENTACION DEL PROYECTO
- OFERTA DE MANTENIMIENTO DE LA APLICACIÓN WEB
- PLANIFICACIÓN DE LOS PROXIMOS PASOS PARA REALIZAR EN EL AÑO 2026
Los bloques los vamos a trabajar en SPRINTS, priorizando que cada Sprint obtenga una versión del primer entregable, de manera que siempre podamos dar opciones de validación al equipo de FerMar SL y no perder tiempo innecesario implementando requisitos que no desean en octubre.
FUENTES DE DATOS¶
El equipo de Facility Management de FerMar SL es una area de soporte a la operación de FerMar SL que tiene delegadas todas las responsabilidades para garantizar la disponibilidad de los immuebles de toda la organización y cuentan con un modelo de gestión de mantenimiento, servicios y utilíties bien consolidado en toda su organización.
Su sistema de gestión CAFM está integrado con el ERP global de la organización, pero permite disponer de información complementaria de cada una de las contrataciones que se realizan para darle contexto y analisis.
Este CAFM explota toda su información en una herramienta de Business Intelligent que llaman BO, que es de donde nos han remitido los datos de gasto FM desde enero 2021 hasta agosto 2025.
El fichero entregado cuenta con 36 variables entre las que se encuentra la variable objetivo.
Hemos realizado una serie de reuniones para comprender cada una de las variables y evaluar sus dependencias, puesto muchas de ellas son subagrupaciones de otras o bien funciones lineales entre varias.
Asimismo, dada la confidencialidad del proyecto, se ha solicitado sustituir alguna variable codificada según su clave primaria en la base de datos relacional origen sin perder información con esta redenominación de sus clases.
TABLA DE ANALISIS DE LA FUENTE DE DATOS
Por otro lado, hemos solicitado otras variables que nos pueden aportar contexto y mejorar el rendimiento del modelo como pueden ser variables de ingresos de los immuebles así como de condiciones medioambientales interiores (las exteriores también deberían contemplarse, pero requieren de mucho más tiempo de recopilación).
TABLA PIXELADO DE DATOS DE TODAS LAS FUENTES
FUENTE DE DATOS 1 (FD1): DATOS DE COSTE - 100% DE LOS IMMUEBLES DEL GRUPO (202101-202508) - Actualmente disponible
- FD1 - Registro mensual del año y mes de cierre de la contratación - inicio del devengo y por lo tanto de la emisión de la factura.
FUENTE DE DATOS 2 (FD2): GAMING OPERATION INDICATORS DE LOS IMMUEBLES TIPO 1 (202401 - 202508) - Aun no disponible
- FD2 - Registro diario de ingresos.
FUENTE DE DATOS 3 (FD3): CONDICIONES AMBIENTALES DE LOS IMMUEBLES TIPO 2 (202309-202508) - Aun no disponible
- FD3 - Registro horario de temperatura, humedad y CO2.
Cada una de las fuentes de datos tiene su periodicidad de registros que deberemos abordar en caso que finalmente se obtengan y debamos unir las tablas por el ID_BUILDING
.
Dado el poco tiempo que se dispone para entregar resultados que puedan compararse con la previsiones reales realizadas, vamos solo a realizar la modelización con la fuente de datos 1, FD1.
Es posible que si obtenemos métricas de validación del modelo o los modelos entrenados, nos veamos a añadir más contexto con datos de las fuentes 2 y 3 si nos las entregan.
Adicionamente a estas fuentes de datos, hemos usado un sistema de catálogos que nos permite encriptar la información confidencial con un sistema de códigos y relacionar variables con otras para dar mayor contexto al dataset para el entrenamiento.
ANALISIS EXPLORATIVOS DE DATOS DE LA FD1¶
Partimos de la fuente de datos 1, la que contiene el histórico de contrataciones de servicios y mantenimientos de todas las sedes de la organización objeto del proyecto.
Cargamos e importamos las librerias que se van a usar.¶
A medida que el código de este notebook nos requiere el uso de alguna de ellas, las vamos subiendo a cabecera para dejar el código libre de importaciones y disponer de todas las necesarias en este bloque.
#Instalamos libreria unidecode para la conversión de textos con tilde a sin tilde
!pip install unidecode
Collecting unidecode Downloading Unidecode-1.4.0-py3-none-any.whl.metadata (13 kB) Downloading Unidecode-1.4.0-py3-none-any.whl (235 kB) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/235.8 kB ? eta -:--:-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 235.5/235.8 kB 13.9 MB/s eta 0:00:01 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 235.8/235.8 kB 5.3 MB/s eta 0:00:00 Installing collected packages: unidecode Successfully installed unidecode-1.4.0
# Instalamos herramienta nbconvert de Jupyter que sirve para convertir notebooks
# en otros formatos como HTML, PDF o Markdown.
!pip install -q "nbconvert>=7.0.0"
# Importamos las librerías necesarias para el análisis exploratorio de datos (AED)
# pandas: manipulación y análisis de datos en estructuras tipo DataFrame
import pandas as pd
# numpy: operaciones matemáticas y funciones estadísticas de bajo nivel
import numpy as np
# matplotlib.pyplot: librería de visualización básica para gráficos
import matplotlib.pyplot as plt
# seaborn: librería de visualización basada en matplotlib, más orientada a análisis estadístico
import seaborn as sns
# unidecode: librería de conversión de texto a código ASCII (sin tildes ni ñ ni caracteres especiales)
from unidecode import unidecode
# os: es un módulo de Python que permite interactuar con el sistema operativo
# (crear carpetas, gestionar rutas, archivos, procesos)
import os
# Re: librería estándar de python que nos permite buscar, extraer y transformar cadenas de texto mediante expresiones regulares.
import re
# warnings: Libreria estándar de Python que permite gestionar y controlar los mensajes de advertencia que aparecen durante la ejecución del código
import warnings
warnings.filterwarnings("ignore")
# statsmodels: libreria de modelización estadística especializada en series temporales y econometría.
# Incluye implementaciones clásicas como ARIMA, SARIMA y Holt-Winters (ETS).
import statsmodels.api as sm
# datetime:
from datetime import datetime
# Json:
import json
import sys
import glob
# Intentamos importar scipy.sparse; si no está, seguimos con numpy
try:
from scipy import sparse as sp
SCIPY_AVAILABLE = True
except Exception:
SCIPY_AVAILABLE = False
import matplotlib.image as mpimg
import requests
import hashlib, shutil, math, textwrap
Librerias para algoritmos predictivos
# statsmodels para ExponentialSmoothing (Holt-Winters)
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from sklearn.metrics import mean_squared_error
# Importamos STL para descomponer la serie en tendencia + estacionalidad + residuo
from statsmodels.tsa.seasonal import STL
from typing import Dict, Any, List, Tuple, Optional
# statsmodels para ETS/SARIMA ===
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from statsmodels.tsa.stattools import acf
from statsmodels.tsa.holtwinters import SimpleExpSmoothing, ExponentialSmoothing
from statsmodels.tsa.arima.model import ARIMA
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tools.sm_exceptions import ConvergenceWarning
# Silenciamos advertencias ruidosas de statsmodels
warnings.filterwarnings("ignore", category=ConvergenceWarning)
warnings.filterwarnings("ignore", message="Non-invertible.*")
warnings.filterwarnings("ignore", message="Non-stationary.*")
from sklearn.metrics import mean_absolute_error
from scipy.stats import median_abs_deviation, mannwhitneyu
# =========================
# Bloque 0) Imports
# =========================
# Cargamos librerías estándar y de modelización. Hacemos importes condicionales para ser robustos.
# Modelos statsmodels (ETS/Holt/Theta/ARIMA)
try:
from statsmodels.tsa.holtwinters import ExponentialSmoothing
except Exception:
ExponentialSmoothing = None
try:
from statsmodels.tsa.statespace.sarimax import SARIMAX
except Exception:
SARIMAX = None
try:
from statsmodels.tsa.forecasting.theta import ThetaModel
except Exception:
ThetaModel = None
# Importamos statsmodels si está disponible; si no, caeremos a baselines
try:
from statsmodels.tsa.holtwinters import ExponentialSmoothing
except Exception:
ExponentialSmoothing = None
try:
from statsmodels.tsa.statespace.sarimax import SARIMAX
except Exception:
SARIMAX = None
try:
from statsmodels.tsa.forecasting.theta import ThetaModel
except Exception:
ThetaModel = None
from pandas.api.types import is_datetime64tz_dtype
from pathlib import Path
from __future__ import annotations
# Intentamos importar statsmodels; si no está, dejamos None y haremos fallbacks.
try:
from statsmodels.tsa.holtwinters import ExponentialSmoothing
except Exception:
ExponentialSmoothing = None
try:
from statsmodels.tsa.statespace.sarimax import SARIMAX
except Exception:
SARIMAX = None
try:
from statsmodels.tsa.forecasting.theta import ThetaModel
except Exception:
ThetaModel = None
Conectamos con nuestro DRIVE¶
Nos conectamos al Drive donde tenemos la carpeta del proyecto.
# Montamos Google Drive para acceder al archivo desde Colab
from google.colab import drive
drive.mount('/content/drive')
Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount("/content/drive", force_remount=True).
Cargamos el dataset FD1 que nos han entregado con el histórico de contratación del equipo de FM.¶
Cargamos el archivos de datos FD1 desde la carpeta del proyecto: Fuente_datos.
# Definimos la ruta al archivo Excel en tu Google Drive
ruta_excel = "/content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/HECHOS/FAMA_Encriptado_21-25.xlsx"
# Nombre de la hoja que contiene los datos
hoja = "Desref_Deta_encri_fin_valo21-25"
# Leemos el archivo Excel en un DataFrame de pandas
df_fd1_full = pd.read_excel(ruta_excel, sheet_name=hoja, engine="openpyxl")
# Mostramos las primeras filas para comprobar la carga
df_fd1_full.head()
Comprobaciones, análisis inicial y transformación del dataset¶
Vamos a comprobar la estructura completa del dataframe cargado: número de filas, tipos de datos y posibles valores únicos clave.
# Número de filas y columnas
print("Dimensiones del DataFrame:", df_fd1_full.shape)
# Tipos de datos por columna
print("\nTipos de datos:")
print(df_fd1_full.dtypes)
# Valores únicos en columnas clave para revisar codificación
for col in ["COST_TYPE", "SUPPLIER_TYPE", "COUNTRY", "FM_COST_TYPE"]:
print(f"\nValores únicos en {col}:")
print(df_fd1_full[col].unique())
Dimensiones del DataFrame: (324439, 15) Tipos de datos: ID_ORDER object COST_TYPE object COUNTRY object ID_BUILDING int64 YEAR_MONTH int64 FM_RESPONSIBLE object FM_COST_TYPE object COST object ID_CUSTOMER int64 ID_SUPPLIER int64 ID_BUSINESS_UNIT int64 SUPPLIER_TYPE object MONTH int64 YEAR int64 QUARTER int64 dtype: object Valores únicos en COST_TYPE: ['Gasto' 'Inversión'] Valores únicos en SUPPLIER_TYPE: ['EXTERNO' 'INTERNO' 0] Valores únicos en COUNTRY: ['España' 'México' 'Costa Rica' 'Panamá' 'Perú' 'República Dominicana' 'Colombia' 'Italia' 'Marruecos'] Valores únicos en FM_COST_TYPE: ['Mtto. Contratos' 'Servicios Ctto.' 'Suministros' 'Eficiencia Energética' 'Servicios Extra' 'Obras' 'Mtto. Correctivo' 'Licencias' 'Control y Reporting' 'Dirección FM' 'Oficina Técnica' 'Real Estate' 'Vacío' 'Global' 'Operaciones']
Principales características del Dataframe cargado:
Tamaño: 324.439 registros y 15 columnas, lo que nos confirma que estamos trabajando con un histórico grande.
Tipos de datos:
COST
aparece como object en lugar de numérico, será necesario convertirlo a float.
YEAR_MONTH
está como int64 identificando un formato YYYYMM. No lo vamos a transformar porque ya tenemos otras columnas que nos ofrecen esta información.
Valores únicos relevantes:
COST_TYPE: usa etiquetas en castellano (Gasto, Inversión).
SUPPLIER_TYPE: hay un valor 0 junto a EXTERNO e INTERNO. Ese 0 parece un error de codificación. Debemos analizarlo.
FM_COST_TYPE: hay categorías variadas, etiquetadas con una etiqueta de“Vacío”, cuando debería ser NaN. Debemos analizarlo.
Vamos a convertir a float los valores de la columna COST
para convertirla a numérica.
# Creamos una nueva columna 'cost_float' a partir de COST
# Si algún valor no se puede convertir, quedará como NaN
df_fd1_full["cost_float"] = pd.to_numeric(df_fd1_full["COST"], errors="coerce")
# Comprobamos el tipo de dato de la nueva columna
print(df_fd1_full["cost_float"].dtype)
# Contamos cuántos valores quedaron como NaN
print("Valores NaN en cost_float:", df_fd1_full["cost_float"].isna().sum())
# Mostramos algunas filas para verificar
df_fd1_full[["COST", "cost_float"]].head()
float64 Valores NaN en cost_float: 47
COST | cost_float | |
---|---|---|
0 | 77 | 77.00 |
1 | 36.95 | 36.95 |
2 | 36.95 | 36.95 |
3 | 605.45 | 605.45 |
4 | 277.19 | 277.19 |
Vamos a averiguar cuales son estos valores NaN que no han podido ser convertidos a float.
# Filtramos los registros donde cost_float es NaN
df_cost_nan = df_fd1_full[df_fd1_full["cost_float"].isna()]
# Mostramos el número de registros y algunas columnas clave para revisar
print("Número de registros con cost_float NaN:", len(df_cost_nan))
# Seleccionamos columnas útiles para revisar qué ocurre
cols_revision = ["ID_ORDER", "COST", "COST_TYPE", "COUNTRY", "YEAR_MONTH", "FM_COST_TYPE"]
df_cost_nan[cols_revision].head(47)
Número de registros con cost_float NaN: 47
ID_ORDER | COST | COST_TYPE | COUNTRY | YEAR_MONTH | FM_COST_TYPE | |
---|---|---|---|---|---|---|
59707 | F001203888 | #DIV/0 | Gasto | Marruecos | 202304 | Suministros |
59710 | F001203891 | #DIV/0 | Gasto | Marruecos | 202305 | Suministros |
59713 | F001203894 | #DIV/0 | Gasto | Marruecos | 202306 | Suministros |
59716 | F001203897 | #DIV/0 | Gasto | Marruecos | 202307 | Suministros |
59719 | F001203900 | #DIV/0 | Gasto | Marruecos | 202308 | Suministros |
59722 | F001203903 | #DIV/0 | Gasto | Marruecos | 202309 | Suministros |
59725 | F001203906 | #DIV/0 | Gasto | Marruecos | 202310 | Suministros |
59728 | F001203909 | #DIV/0 | Gasto | Marruecos | 202311 | Suministros |
59731 | F001203912 | #DIV/0 | Gasto | Marruecos | 202312 | Suministros |
59734 | F001203915 | #DIV/0 | Gasto | Marruecos | 202401 | Suministros |
59737 | F001203918 | #DIV/0 | Gasto | Marruecos | 202402 | Suministros |
60568 | F001204797 | #DIV/0 | Gasto | Marruecos | 202403 | Suministros |
60573 | F001204802 | #DIV/0 | Gasto | Marruecos | 202406 | Suministros |
60574 | F001204803 | #DIV/0 | Gasto | Marruecos | 202405 | Suministros |
60578 | F001204807 | #DIV/0 | Gasto | Marruecos | 202406 | Suministros |
62239 | F001206667 | #DIV/0 | Gasto | Marruecos | 202407 | Suministros |
64435 | F001209124 | #DIV/0 | Gasto | Marruecos | 202409 | Suministros |
67530 | F001213812 | #DIV/0 | Gasto | Marruecos | 202410 | Suministros |
69199 | F001215603 | #DIV/0 | Gasto | Marruecos | 202411 | Suministros |
81775 | F001228815 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
81776 | F001228816 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
81777 | F001228817 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
82613 | F001229828 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
82614 | F001229829 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
82615 | F001229830 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
82616 | F001229831 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
85707 | F001235569 | #DIV/0 | Gasto | Marruecos | 202506 | Suministros |
85708 | F001235570 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
85709 | F001235571 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
85710 | F001235572 | #DIV/0 | Gasto | Marruecos | 202509 | Suministros |
85711 | F001235573 | #DIV/0 | Gasto | Marruecos | 202510 | Suministros |
85712 | F001235574 | #DIV/0 | Gasto | Marruecos | 202511 | Suministros |
85713 | F001235575 | #DIV/0 | Gasto | Marruecos | 202512 | Suministros |
85714 | F001235578 | #DIV/0 | Gasto | Marruecos | 202506 | Suministros |
85715 | F001235579 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
85716 | F001235580 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
85717 | F001235581 | #DIV/0 | Gasto | Marruecos | 202509 | Suministros |
85718 | F001235582 | #DIV/0 | Gasto | Marruecos | 202510 | Suministros |
85719 | F001235583 | #DIV/0 | Gasto | Marruecos | 202511 | Suministros |
85720 | F001235584 | #DIV/0 | Gasto | Marruecos | 202512 | Suministros |
86197 | F001236253 | #DIV/0 | Gasto | Marruecos | 202506 | Suministros |
86198 | F001236254 | #DIV/0 | Gasto | Marruecos | 202507 | Suministros |
86199 | F001236255 | #DIV/0 | Gasto | Marruecos | 202508 | Suministros |
86200 | F001236256 | #DIV/0 | Gasto | Marruecos | 202509 | Suministros |
86201 | F001236257 | #DIV/0 | Gasto | Marruecos | 202510 | Suministros |
86202 | F001236258 | #DIV/0 | Gasto | Marruecos | 202511 | Suministros |
86203 | F001236259 | #DIV/0 | Gasto | Marruecos | 202512 | Suministros |
Vemos que todos ellos se refieren a los gastos relacionados con el consumo del suministro eléctrico de los immuebles de Marruecos.
Conclusión del equipo FM¶
Como Marruecos e Italia nos han dicho que no debemos incluirlos de momento en el modelo, no vamos a realizar nada, puesto vamos a eliminar sus registros antes de iniciar la modelización.
Ahora pasamos a revisar los valores 0 de la variable SUPPLIER_TYPE
.
# Filtramos los registros donde SUPPLIER_TYPE = 0
df_supplier_zero = df_fd1_full[df_fd1_full["SUPPLIER_TYPE"] == 0]
# Mostramos el número de registros
print("Número de registros con SUPPLIER_TYPE = 0:", len(df_supplier_zero))
# Vemos algunas columnas relevantes para entender el contexto
cols_revision = ["ID_ORDER", "SUPPLIER_TYPE", "ID_SUPPLIER", "COST", "cost_float", "COST_TYPE", "COUNTRY", "YEAR", "MONTH"]
df_supplier_zero[cols_revision].head(20)
Número de registros con SUPPLIER_TYPE = 0: 8
ID_ORDER | SUPPLIER_TYPE | ID_SUPPLIER | COST | cost_float | COST_TYPE | COUNTRY | YEAR | MONTH | |
---|---|---|---|---|---|---|---|---|---|
196330 | 701690066 | 0 | 3006129 | 100 | 100.000000 | Gasto | Perú | 2023 | 5 |
197806 | 701695109 | 0 | 3006129 | 576.190476 | 576.190476 | Gasto | Perú | 2023 | 5 |
200638 | 701701552 | 0 | 3006129 | 114.285714 | 114.285714 | Gasto | Perú | 2023 | 6 |
211469 | 701724829 | 0 | 3006129 | 90.47619 | 90.476190 | Gasto | Perú | 2023 | 9 |
300690 | 701906619 | 0 | 3006129 | 361.904762 | 361.904762 | Gasto | Perú | 2025 | 4 |
308437 | 701919528 | 0 | 3007232 | 923.583333 | 923.583333 | Inversión | Perú | 2025 | 6 |
315020 | 701930289 | 0 | 3007232 | 169.047619 | 169.047619 | Gasto | Perú | 2025 | 7 |
322373 | 701943483 | 0 | 3007232 | 0 | 0.000000 | Gasto | Perú | 2025 | 8 |
Conclusión del equipo FM¶
Al tratarse de 8 casos, hemos decidido solicitar al equipo de FM el tipo de proveedor en función del ID_SUPPLIER para que lo podamos asignar manualmente según un diccionario que lo corrija.
Su respuesta ha sido:
ID_SUPPLIER: 3006129 SUPPLIER_TYPE: EXTERNO
ID_SUPPLIER: 3007232 SUPPLIER_TYPE: EXTERNO
# Definimos un diccionario con las correcciones manuales de SUPPLIER_TYPE
correcciones_supplier_type = {
3006129: "EXTERNO",
3007232: "EXTERNO"
}
# Creamos una nueva columna SUPPLIER_TYPE_MOD con la corrección aplicada
df_fd1_full["SUPPLIER_TYPE_MOD"] = df_fd1_full.apply(
lambda row: correcciones_supplier_type.get(row["ID_SUPPLIER"], row["SUPPLIER_TYPE"]),
axis=1
)
# Comprobamos los valores únicos después de la corrección
print("Valores únicos en SUPPLIER_TYPE_MOD:")
print(df_fd1_full["SUPPLIER_TYPE_MOD"].unique())
# Revisamos los registros corregidos
df_fd1_full[df_fd1_full["ID_SUPPLIER"].isin(correcciones_supplier_type.keys())][
["ID_ORDER", "ID_SUPPLIER", "SUPPLIER_TYPE", "SUPPLIER_TYPE_MOD"]
]
Valores únicos en SUPPLIER_TYPE_MOD: ['EXTERNO' 'INTERNO']
ID_ORDER | ID_SUPPLIER | SUPPLIER_TYPE | SUPPLIER_TYPE_MOD | |
---|---|---|---|---|
196330 | 701690066 | 3006129 | 0 | EXTERNO |
197806 | 701695109 | 3006129 | 0 | EXTERNO |
200638 | 701701552 | 3006129 | 0 | EXTERNO |
211469 | 701724829 | 3006129 | 0 | EXTERNO |
300690 | 701906619 | 3006129 | 0 | EXTERNO |
308437 | 701919528 | 3007232 | 0 | EXTERNO |
315020 | 701930289 | 3007232 | 0 | EXTERNO |
322373 | 701943483 | 3007232 | 0 | EXTERNO |
Finalmente vamos a revisar cuántos registros tienen la etiqueta "Vacío" en la columna FM_COST_TYPE y en qué contexto aparecen.
# Contamos cuántos registros tienen "Vacío" en FM_COST_TYPE
n_vacio = (df_fd1_full["FM_COST_TYPE"] == "Vacío").sum()
print("Número de registros con FM_COST_TYPE = 'Vacío':", n_vacio)
# Vemos algunas filas para revisar en qué contexto aparece
cols_revision = ["ID_ORDER", "COST_TYPE", "COUNTRY", "YEAR", "MONTH", "FM_RESPONSIBLE", "FM_COST_TYPE", "cost_float"]
df_fd1_full[df_fd1_full["FM_COST_TYPE"] == "Vacío"][cols_revision].head(20)
Número de registros con FM_COST_TYPE = 'Vacío': 17
ID_ORDER | COST_TYPE | COUNTRY | YEAR | MONTH | FM_RESPONSIBLE | FM_COST_TYPE | cost_float | |
---|---|---|---|---|---|---|---|---|
175309 | 1643232 | Gasto | España | 2023 | 1 | Vacío | Vacío | 0.00 |
175315 | 1643238 | Gasto | España | 2023 | 1 | Vacío | Vacío | 300.00 |
181588 | 1656972 | Gasto | España | 2023 | 3 | Vacío | Vacío | 693.60 |
181591 | 1656975 | Gasto | España | 2023 | 3 | Vacío | Vacío | 0.00 |
187654 | 1670590 | Gasto | España | 2023 | 4 | Vacío | Vacío | 0.00 |
187655 | 1670591 | Gasto | España | 2023 | 3 | Vacío | Vacío | 1504.80 |
189482 | 1674466 | Gasto | España | 2023 | 3 | Vacío | Vacío | 61.01 |
189483 | 1674467 | Gasto | España | 2023 | 4 | Vacío | Vacío | 0.00 |
189484 | 1674468 | Gasto | España | 2023 | 6 | Vacío | Vacío | 80.40 |
197827 | 701695169 | Gasto | España | 2023 | 6 | Vacío | Vacío | 0.00 |
197828 | 701695170 | Gasto | España | 2023 | 6 | Vacío | Vacío | 0.00 |
233465 | 701772199 | Gasto | España | 2024 | 2 | Vacío | Vacío | 0.00 |
234052 | 701773448 | Gasto | Marruecos | 2025 | 2 | Vacío | Vacío | 0.00 |
234053 | 701773450 | Inversión | Marruecos | 2025 | 2 | Vacío | Vacío | 0.00 |
234055 | 701773454 | Gasto | Marruecos | 2025 | 2 | Vacío | Vacío | 0.00 |
234713 | 701774821 | Gasto | España | 2024 | 2 | Vacío | Vacío | 0.00 |
319642 | 701938246 | Gasto | España | 2025 | 7 | Vacío | Vacío | 255.96 |
Son 17 registros en total.
Aparecen principalmente en España (2023, 2024 y 2025), y algunos en Marruecos (que ya sabemos que se excluirán en la modelización).
Tanto FM_RESPONSIBLE
como FM_COST_TYPE
están etiquetados como "Vacío".
Muchos de estos registros tienen coste 0.00, aunque no todos.
Esto confirma que "Vacío" se usa como relleno cuando no se ha registrado la familia de coste ni el responsable.
Conclusión del equipo FM¶
Hemos solicitado al equipo de FM de FerMar SL que nos analicen esta casuística porque en caso que incrementase en un futuro, podria perjudicar la modelización. Para el caso que nos ocupa, 17 registros no son representativos frente el volumen de registros del dataframe, por lo que vamos a eliminarlos, generando un nuevo dataset df_fd1_v1.
# Creamos un nuevo dataset eliminando los registros con FM_COST_TYPE = "Vacío"
df_fd1_v1 = df_fd1_full[df_fd1_full["FM_COST_TYPE"] != "Vacío"].copy()
# Comprobamos el número de registros antes y después
print("Filas en df_fd1_full:", df_fd1_full.shape[0])
print("Filas en df_fd1_v1:", df_fd1_v1.shape[0])
# Validamos que ya no queden registros con FM_COST_TYPE = "Vacío"
print("Registros con FM_COST_TYPE = 'Vacío' en df_fd1_v1:",
(df_fd1_v1["FM_COST_TYPE"] == "Vacío").sum())
Filas en df_fd1_full: 324439 Filas en df_fd1_v1: 324422 Registros con FM_COST_TYPE = 'Vacío' en df_fd1_v1: 0
Analizamos los valores únicos de FM_RESPONSIBLE
.
# Valores únicos en FM_RESPONSIBLE
valores_unicos_resp = df_fd1_v1["FM_RESPONSIBLE"].unique()
print("Valores únicos en FM_RESPONSIBLE:", valores_unicos_resp)
# Conteo de registros por clase de FM_RESPONSIBLE
conteo_resp = df_fd1_v1["FM_RESPONSIBLE"].value_counts().sort_values(ascending=False)
print("\nDistribución de registros por clase:")
print(conteo_resp)
Valores únicos en FM_RESPONSIBLE: ['Mantenimiento Multipunto' 'Mantenimiento' 'Eficiencia Energética' 'Licencias' 'Dirección FM' 'Obras Proyectos' 'Oficina Técnica' 'Gestión Espacios' 'Control y Reporting' 'Real Estate' 'Global' 'Operaciones'] Distribución de registros por clase: FM_RESPONSIBLE Mantenimiento 247288 Eficiencia Energética 48142 Mantenimiento Multipunto 12729 Obras Proyectos 7975 Licencias 5951 Gestión Espacios 2091 Oficina Técnica 140 Dirección FM 64 Global 22 Real Estate 10 Control y Reporting 6 Operaciones 4 Name: count, dtype: int64
Nos aseguramos que no queden valores "Vacío" y así se confirma.
Observamos que puede existir correlación entre las variables FM_RESPONSIBLE
y FM_COST_TYPE
. Más adelante lo evaluaremos.
También observamos que la variable FM_RESPONSIBLE
está muy desbalanceada en pro de la clase Mantenimiento
, Eficiencia Energética
y Mantenimiento Multipunto
.
Conclusiones del equipo de FM¶
Hemos solicitado al equipo de FM de FerMar S.L. que nos expliquen que diferencia hay entre Mantenimiento
y Mantenimiento Multipunto
y han concluido que se trata de lo mismo a dia de hoy, aunque en años anteriores si tenía su sentido diferenciarlo. Mas adelante vamos a fusionar estas dos clases si no añaden valor diferencial.
Vamos a mirar cual es el coste por año de las FM_RESPONSIBLE
con menos representación para poder realizar la consula al equipo FM de manera más objetiva. Añadiremos los ID_BUILDING
como segmentación de la respuesta. Se entiende clase minoritaria la que tiene menos de 2000 registros.
# Identificamos las clases minoritarias (<2000 registros)
conteo_resp = df_fd1_v1["FM_RESPONSIBLE"].value_counts()
clases_minoritarias = conteo_resp[conteo_resp < 2000].index.tolist()
print("Clases minoritarias (<2000 registros):", clases_minoritarias)
# Filtramos solo esas clases
df_minoritarias = df_fd1_v1[df_fd1_v1["FM_RESPONSIBLE"].isin(clases_minoritarias)].copy()
# Tabla pivoteada FM_RESPONSIBLE x YEAR
pivot_responsable_year = (
df_minoritarias
.groupby(["FM_RESPONSIBLE", "YEAR"])["cost_float"]
.sum()
.reset_index()
.pivot(index="FM_RESPONSIBLE", columns="YEAR", values="cost_float")
.fillna(0)
)
print("\nCoste total por FM_RESPONSIBLE (clases minoritarias) y Año:")
display(pivot_responsable_year)
# Detalle incluyendo ID_BUILDING
coste_minoritarias_building = (
df_minoritarias
.groupby(["FM_RESPONSIBLE", "YEAR", "ID_BUILDING"])["cost_float"]
.sum()
.reset_index()
.sort_values(by=["FM_RESPONSIBLE", "YEAR", "cost_float"], ascending=[True, True, False])
)
print("\nDetalle coste por FM_RESPONSIBLE, Año e ID_BUILDING:")
coste_minoritarias_building
Clases minoritarias (<2000 registros): ['Oficina Técnica', 'Dirección FM', 'Global', 'Real Estate', 'Control y Reporting', 'Operaciones'] Coste total por FM_RESPONSIBLE (clases minoritarias) y Año:
YEAR | 2021 | 2022 | 2023 | 2024 | 2025 |
---|---|---|---|---|---|
FM_RESPONSIBLE | |||||
Control y Reporting | 6750.00 | 16980.00 | 0.000000 | 0.00 | 0.00 |
Dirección FM | 1649.15 | 8651.36 | 8506.600000 | 7357.74 | 4512.41 |
Global | 0.00 | 0.00 | 9618.181818 | 200.00 | 18497.40 |
Oficina Técnica | 0.00 | 23644.66 | 6918.000000 | 24798.11 | 13442.30 |
Operaciones | 0.00 | 0.00 | 1158.300000 | 0.00 | 0.00 |
Real Estate | 0.00 | 2477.00 | 62.850000 | 0.00 | 142.15 |
Detalle coste por FM_RESPONSIBLE, Año e ID_BUILDING:
FM_RESPONSIBLE | YEAR | ID_BUILDING | cost_float | |
---|---|---|---|---|
0 | Control y Reporting | 2021 | 18 | 6750.00 |
1 | Control y Reporting | 2022 | 18 | 16980.00 |
2 | Control y Reporting | 2022 | 1000667 | 0.00 |
3 | Dirección FM | 2021 | 18 | 1649.15 |
4 | Dirección FM | 2022 | 18 | 8651.36 |
... | ... | ... | ... | ... |
76 | Real Estate | 2025 | 619 | 0.00 |
78 | Real Estate | 2025 | 1001131 | 0.00 |
79 | Real Estate | 2025 | 1001155 | 0.00 |
80 | Real Estate | 2025 | 1001512 | 0.00 |
81 | Real Estate | 2025 | 1001526 | 0.00 |
82 rows × 4 columns
El análisis muestra claramente que aunque estas clases de FM_RESPONSIBLE
son minoritarias en número de registros, algunas concentran importes relevantes en determinados años y edificios:
Oficina Técnica: es la minoritaria con mayor peso económico (picos fuertes en 2022, 2024 y 2025, especialmente en el edificio con ID_BUILDING = 18).
Dirección FM: costes estables año a año, centralizados casi siempre en el mismo edificio (ID_BUILDING = 18).
Global: surge en 2023 y crece en 2025, disperso en varios edificios (ej. 1001296, 1000998, 1001520).
Control y Reporting: se concentra en 2021-2022 y básicamente en ID_BUILDING = 18.
Operaciones y Real Estate: importes muy bajos y con poca relevancia.
No son muchas órdenes, pero sí hay en algunos casos concentraciones de gasto relevantes en ciertos años y edificios concretos.
Con esta información les hemos planteado al equipo de FM la consulta de su relevancia en el proyecto de una manera más objetiva.
Conclusiones del equipo de FM¶
Una vez realizada la consulta, la respuesta del equipo de FM ha sido que estos FM_RESPONSIBLE
minoritarios no actúan en el perímetro de immuebles en los que debemos predecir los gastos, así que más adelante podremos eliminar sus registros para dejar más limpio el dataset.
Antes nos gustaría entender mejor esta respuesta, motivo por el que no vamos a ejecutar la eliminación hasta clarificar el caso.
Ahora analizamos el tipo de dato de cada variables y numero de valores únicos.
# Tipos de datos del dataset modificado
print("Tipos de datos en df_fd1_v1:")
print(df_fd1_v1.dtypes)
# Número de valores únicos por columna
print("\nNúmero de valores únicos por columna:")
valores_unicos = df_fd1_v1.nunique().sort_values(ascending=False)
print(valores_unicos)
Tipos de datos en df_fd1_v1: ID_ORDER object COST_TYPE object COUNTRY object ID_BUILDING int64 YEAR_MONTH int64 FM_RESPONSIBLE object FM_COST_TYPE object COST object ID_CUSTOMER int64 ID_SUPPLIER int64 ID_BUSINESS_UNIT int64 SUPPLIER_TYPE object MONTH int64 YEAR int64 QUARTER int64 cost_float float64 SUPPLIER_TYPE_MOD object dtype: object Número de valores únicos por columna: ID_ORDER 324421 COST 88837 cost_float 88836 ID_SUPPLIER 2594 ID_BUILDING 722 ID_CUSTOMER 181 YEAR_MONTH 60 FM_COST_TYPE 14 ID_BUSINESS_UNIT 14 FM_RESPONSIBLE 12 MONTH 12 COUNTRY 9 YEAR 5 QUARTER 4 SUPPLIER_TYPE 3 COST_TYPE 2 SUPPLIER_TYPE_MOD 2 dtype: int64
Una vez realizado ya podemos clasificar las variables y categorizarls según incidan o no en un modelo predictivo como el que nos solicitan.
Vamos a generar un nuevo DataFrame resumen llamado esquema_variables
que incluye:
Nombre de cada variable.
Tipo real (
tipo_variables
).Número de valores únicos (
valores_unicos
).Tipo propuesto para modelización (
tipo_propuesto
).Una justificación resumida (
justificacion
).
De esta forma, tenemos documentada la naturaleza de cada campo del dataset df_fd1_v1
, lo que te servirá como esquema de referencia en la preparación y durante la modelización.
# Tabla de clasificación de variables con tipo propuesto y justificación resumida
# Obtenemos tipos y número de únicos desde el dataset
tipo_variables = df_fd1_v1.dtypes.astype(str).rename("tipo_variables")
valores_unicos = df_fd1_v1.nunique().rename("valores_unicos")
# Definimos el rol de cada variable
rol_propuesto = {
"ID_ORDER": "identificador",
"COST_TYPE": "categorica",
"COUNTRY": "categorica",
"ID_BUILDING": "identificador",
"YEAR_MONTH": "temporal",
"FM_RESPONSIBLE": "categorica",
"FM_COST_TYPE": "categorica",
"COST": "texto_original",
"ID_CUSTOMER": "identificador",
"ID_SUPPLIER": "identificador",
"ID_BUSINESS_UNIT": "identificador",
"SUPPLIER_TYPE": "categorica_ref",
"MONTH": "temporal",
"YEAR": "temporal",
"QUARTER": "temporal",
"cost_float": "numerica_continua",
"SUPPLIER_TYPE_MOD": "categorica"
}
# Justificación breve por variable
justificacion = {
"ID_ORDER": "Clave técnica única por fila; no aporta patrones generalizables.",
"COST_TYPE": "Etiqueta OPEX/CAPEX (Gasto/Inversión); categórica de alto valor explicativo.",
"COUNTRY": "Diferencias regulatorias/mercado; categórica geográfica.",
"ID_BUILDING": "Identificador del espacio; el código en sí no es informativo.",
"YEAR_MONTH": "Periodo YYYYMM; útil para agregaciones temporales si se requiere.",
"FM_RESPONSIBLE": "Vertical responsable; categórica potencialmente correlacionada con la familia de gasto.",
"FM_COST_TYPE": "Familia del gasto (contrato/bajo demanda, etc.); categórica clave.",
"COST": "Valor original de coste en texto; se mantiene para trazabilidad.",
"ID_CUSTOMER": "Clave de sociedad; mejor usar rasgos derivados que el código.",
"ID_SUPPLIER": "Clave de proveedor; preferible usar atributos derivados (tipo, histórico).",
"ID_BUSINESS_UNIT": "Clave de división; el número no tiene significado intrínseco.",
"SUPPLIER_TYPE": "Categoría original con codificación inconsistente; queda como referencia.",
"MONTH": "Mes numérico; temporal para agregaciones.",
"YEAR": "Año; temporal para cortes y tendencias.",
"QUARTER": "Trimestre; temporal para agregaciones trimestrales.",
"cost_float": "Coste en euros listo para análisis; variable numérica continua.",
"SUPPLIER_TYPE_MOD": "Tipo de proveedor corregido; usar esta en lugar de la original."
}
# Construimos el dataframe de esquema
cols_presentes = [c for c in df_fd1_v1.columns] # preserva el orden actual del dataset
esquema_variables = (
pd.DataFrame(index=cols_presentes)
.join(tipo_variables, how="left")
.join(valores_unicos, how="left")
.reset_index()
.rename(columns={"index": "variable"})
)
# Añadimos rol y justificación
esquema_variables["tipo_variable"] = esquema_variables["variable"].map(rol_propuesto).fillna("por_definir")
esquema_variables["justificacion"] = esquema_variables["variable"].map(justificacion).fillna("Pendiente de definición.")
# Mostramos la tabla
esquema_variables
variable | tipo_variables | valores_unicos | tipo_variable | justificacion | |
---|---|---|---|---|---|
0 | ID_ORDER | object | 324421 | identificador | Clave técnica única por fila; no aporta patron... |
1 | COST_TYPE | object | 2 | categorica | Etiqueta OPEX/CAPEX (Gasto/Inversión); categór... |
2 | COUNTRY | object | 9 | categorica | Diferencias regulatorias/mercado; categórica g... |
3 | ID_BUILDING | int64 | 722 | identificador | Identificador del espacio; el código en sí no ... |
4 | YEAR_MONTH | int64 | 60 | temporal | Periodo YYYYMM; útil para agregaciones tempora... |
5 | FM_RESPONSIBLE | object | 12 | categorica | Vertical responsable; categórica potencialment... |
6 | FM_COST_TYPE | object | 14 | categorica | Familia del gasto (contrato/bajo demanda, etc.... |
7 | COST | object | 88837 | texto_original | Valor original de coste en texto; se mantiene ... |
8 | ID_CUSTOMER | int64 | 181 | identificador | Clave de sociedad; mejor usar rasgos derivados... |
9 | ID_SUPPLIER | int64 | 2594 | identificador | Clave de proveedor; preferible usar atributos ... |
10 | ID_BUSINESS_UNIT | int64 | 14 | identificador | Clave de división; el número no tiene signific... |
11 | SUPPLIER_TYPE | object | 3 | categorica_ref | Categoría original con codificación inconsiste... |
12 | MONTH | int64 | 12 | temporal | Mes numérico; temporal para agregaciones. |
13 | YEAR | int64 | 5 | temporal | Año; temporal para cortes y tendencias. |
14 | QUARTER | int64 | 4 | temporal | Trimestre; temporal para agregaciones trimestr... |
15 | cost_float | float64 | 88836 | numerica_continua | Coste en euros listo para análisis; variable n... |
16 | SUPPLIER_TYPE_MOD | object | 2 | categorica | Tipo de proveedor corregido; usar esta en luga... |
Dejamos organizada el dataframe esquema_variables
según el tipo_variable
.
# Ordenamos la tabla por tipo_propuesto y después por nombre de variable
esquema_variables_ordenado = esquema_variables.sort_values(
by=["tipo_variable", "variable"]
).reset_index(drop=True)
# Mostramos la tabla ordenada
esquema_variables_ordenado
variable | tipo_variables | valores_unicos | tipo_variable | justificacion | |
---|---|---|---|---|---|
0 | COST_TYPE | object | 2 | categorica | Etiqueta OPEX/CAPEX (Gasto/Inversión); categór... |
1 | COUNTRY | object | 9 | categorica | Diferencias regulatorias/mercado; categórica g... |
2 | FM_COST_TYPE | object | 14 | categorica | Familia del gasto (contrato/bajo demanda, etc.... |
3 | FM_RESPONSIBLE | object | 12 | categorica | Vertical responsable; categórica potencialment... |
4 | SUPPLIER_TYPE_MOD | object | 2 | categorica | Tipo de proveedor corregido; usar esta en luga... |
5 | SUPPLIER_TYPE | object | 3 | categorica_ref | Categoría original con codificación inconsiste... |
6 | ID_BUILDING | int64 | 722 | identificador | Identificador del espacio; el código en sí no ... |
7 | ID_BUSINESS_UNIT | int64 | 14 | identificador | Clave de división; el número no tiene signific... |
8 | ID_CUSTOMER | int64 | 181 | identificador | Clave de sociedad; mejor usar rasgos derivados... |
9 | ID_ORDER | object | 324421 | identificador | Clave técnica única por fila; no aporta patron... |
10 | ID_SUPPLIER | int64 | 2594 | identificador | Clave de proveedor; preferible usar atributos ... |
11 | cost_float | float64 | 88836 | numerica_continua | Coste en euros listo para análisis; variable n... |
12 | MONTH | int64 | 12 | temporal | Mes numérico; temporal para agregaciones. |
13 | QUARTER | int64 | 4 | temporal | Trimestre; temporal para agregaciones trimestr... |
14 | YEAR | int64 | 5 | temporal | Año; temporal para cortes y tendencias. |
15 | YEAR_MONTH | int64 | 60 | temporal | Periodo YYYYMM; útil para agregaciones tempora... |
16 | COST | object | 88837 | texto_original | Valor original de coste en texto; se mantiene ... |
Analisis estadístico univariable
# Resumen estadístico de las variables numéricas del dataset
df_fd1_v1.describe().T
count | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|
ID_BUILDING | 324422.0 | 5.061269e+05 | 4.999032e+05 | 2.00 | 1092.0 | 1000004.000 | 1000666.0 | 1.001604e+06 |
YEAR_MONTH | 324422.0 | 2.023162e+05 | 1.359207e+02 | 202101.00 | 202206.0 | 202308.000 | 202410.0 | 2.025120e+05 |
ID_CUSTOMER | 324422.0 | 1.237584e+06 | 1.476999e+06 | 3.00 | 1399.0 | 2362.000 | 3001496.0 | 3.007033e+06 |
ID_SUPPLIER | 324422.0 | 2.454111e+06 | 1.161188e+06 | 4.00 | 3001882.0 | 3003587.000 | 3004886.0 | 3.007643e+06 |
ID_BUSINESS_UNIT | 324422.0 | 2.202848e+05 | 4.143526e+05 | 0.00 | 43.0 | 43.000 | 51.0 | 1.000109e+06 |
MONTH | 324422.0 | 6.326192e+00 | 3.319426e+00 | 1.00 | 4.0 | 6.000 | 9.0 | 1.200000e+01 |
YEAR | 324422.0 | 2.023099e+03 | 1.364301e+00 | 2021.00 | 2022.0 | 2023.000 | 2024.0 | 2.025000e+03 |
QUARTER | 324422.0 | 2.444723e+00 | 1.083516e+00 | 1.00 | 2.0 | 2.000 | 3.0 | 4.000000e+00 |
cost_float | 324375.0 | 8.618261e+02 | 9.997411e+03 | -40880.07 | 0.0 | 55.731 | 279.0 | 2.623544e+06 |
Resumimos las observaciones más relevantes:
Variables de identificación / claves técnicas (no predictoras directas)
ID_BUILDING
, ID_CUSTOMER
, ID_SUPPLIER
, ID_BUSINESS_UNIT
:
Sus valores son códigos internos, no representan magnitudes numéricas interpretables.
ID_BUSINESS_UNIT
tiene un valor mínimo de 0, se deberia analizar la cantidad de registros con este valor 0 en esta variable para determinar si es relevante al equipo FM.
El describe muestra medias y desviaciones enormes, que no tienen sentido analítico directo.
Se confirman como identificadores.
Variables temporales
YEAR_MONTH
: varía desde 202101 hasta 202512, lo que confirma que el histórico cubre enero 2021 – agosto 2025.
YEAR
: mínimo 2021 y máximo 2025, todo correcto.
MONTH
y QUARTER
: en el rango esperado (1–12 y 1–4 respectivamente).
Variable de interés (cost_float
)
Count: 324.375 registros con valor numérico (47 NaN ya identificados antes).
Media: ~862 € por orden.
Mediana: 55,73 € → lo que indica que la distribución está muy sesgada (pocos importes muy altos tiran hacia arriba la media).
Percentiles:
25% = 0 € (muchas órdenes con coste cero).
50% = 55,73 €
75% = 279 €
→ El 75% de los pedidos está por debajo de 279 €.
Máximo: 2.623.544 € (orden muy alta que actúa como outlier).
Mínimo: -40.880,07 € (existe algún coste negativo, probablemente corrección/abono).
Analisis del valor 0 en ID_BUSINESS_UNIT
.
# Filtramos los registros con ID_BUSINESS_UNIT = 0
df_bu_cero = df_fd1_v1[df_fd1_v1["ID_BUSINESS_UNIT"] == 0]
# Número de registros con valor 0
print("Número de registros con ID_BUSINESS_UNIT = 0:", len(df_bu_cero))
# Proporción sobre el total
print("Proporción sobre el total:", round(len(df_bu_cero) / len(df_fd1_v1) * 100, 4), "%")
# Revisamos algunas columnas clave de esos registros (incluyendo ID_BUILDING)
cols_revision = [
"ID_ORDER", "COST_TYPE", "COUNTRY", "YEAR", "MONTH",
"FM_RESPONSIBLE", "FM_COST_TYPE", "ID_BUILDING", "cost_float"
]
df_bu_cero[cols_revision].head(23)
Número de registros con ID_BUSINESS_UNIT = 0: 23 Proporción sobre el total: 0.0071 %
ID_ORDER | COST_TYPE | COUNTRY | YEAR | MONTH | FM_RESPONSIBLE | FM_COST_TYPE | ID_BUILDING | cost_float | |
---|---|---|---|---|---|---|---|---|---|
26297 | 1163481 | Gasto | Panamá | 2022 | 7 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
26756 | 1163956 | Gasto | Panamá | 2022 | 8 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
28145 | 1165508 | Gasto | Panamá | 2022 | 9 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
30143 | 1167564 | Gasto | Panamá | 2022 | 10 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
32087 | 1170153 | Gasto | Panamá | 2022 | 11 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
32764 | 1170846 | Gasto | Panamá | 2022 | 12 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
34075 | 1172260 | Gasto | Panamá | 2023 | 1 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
35899 | 1174266 | Gasto | Panamá | 2023 | 2 | Eficiencia Energética | Eficiencia Energética | 1001191 | 55.081818 |
54986 | F001198801 | Gasto | Colombia | 2024 | 2 | Mantenimiento | Mtto. Contratos | 1201 | 106.423400 |
65906 | F001211630 | Gasto | Colombia | 2024 | 8 | Mantenimiento | Mtto. Contratos | 1270 | 174.000000 |
68765 | F001215072 | Gasto | Costa Rica | 2024 | 10 | Mantenimiento | Servicios Ctto. | 1000389 | 138.461538 |
70068 | F001216484 | Gasto | España | 2024 | 10 | Eficiencia Energética | Suministros | 1065 | 4906.100000 |
72132 | F001218726 | Gasto | España | 2024 | 11 | Eficiencia Energética | Suministros | 1065 | 3623.490000 |
73456 | F001220115 | Gasto | España | 2024 | 12 | Eficiencia Energética | Suministros | 1065 | 2570.330000 |
74417 | F001221132 | Gasto | España | 2025 | 1 | Eficiencia Energética | Suministros | 1065 | 5101.810000 |
74522 | F001221239 | Gasto | España | 2025 | 1 | Eficiencia Energética | Suministros | 1065 | 2486.810000 |
77086 | F001223966 | Gasto | España | 2025 | 2 | Eficiencia Energética | Suministros | 1065 | 2869.510000 |
78335 | F001225274 | Gasto | España | 2025 | 3 | Eficiencia Energética | Suministros | 1065 | 2816.320000 |
80347 | F001227364 | Gasto | España | 2025 | 4 | Eficiencia Energética | Suministros | 1065 | 3700.430000 |
82043 | F001229108 | Gasto | España | 2025 | 5 | Eficiencia Energética | Suministros | 1065 | 1173.430000 |
87565 | F001238638 | Gasto | España | 2025 | 6 | Eficiencia Energética | Suministros | 1065 | 1396.150000 |
89004 | F001240130 | Gasto | España | 2025 | 7 | Eficiencia Energética | Suministros | 1065 | 7026.640000 |
90849 | F001243576 | Gasto | España | 2025 | 9 | Mantenimiento | Servicios Ctto. | 1000496 | 22.440000 |
La unidad de negocio viene determinada por el ID_BUILDING
, así que vamos a generar un segundo diccionario que permita corregir esta situación para determinados immuebles en donde el sistema de asignación 0 a la unidad de negocio.
Conclusión del equipo de FM¶
Tabla de relación obtenida del equipo de FM
ID_BUILDING: 1001191 ID_BUSINESS_UNIT: 43
ID_BUILDING: 1000496 ID_BUSINESS_UNIT: 1000021
ID_BUILDING: 1065 ID_BUSINESS_UNIT: 50
ID_BUILDING: 1000389 ID_BUSINESS_UNIT: 43
ID_BUILDING: 1270 ID_BUSINESS_UNIT: 43
ID_BUILDING: 1201 ID_BUSINESS_UNIT: 43
# Diccionario de corrección: ID_BUILDING -> ID_BUSINESS_UNIT correcto
correccion_bu_por_building = {
1001191: 43,
1000496: 1000021,
1065: 50,
1000389: 43,
1270: 43,
1201: 43,
}
# Nueva columna con corrección aplicada solo si ID_BUSINESS_UNIT == 0
df_fd1_v1["ID_BUSINESS_UNIT_MOD"] = np.where(
(df_fd1_v1["ID_BUSINESS_UNIT"] == 0) & (df_fd1_v1["ID_BUILDING"].isin(correccion_bu_por_building.keys())),
df_fd1_v1["ID_BUILDING"].map(correccion_bu_por_building),
df_fd1_v1["ID_BUSINESS_UNIT"]
).astype("int64") # usamos int64 estándar
# Flag para auditar qué filas fueron corregidas
df_fd1_v1["bu_corr_flag"] = (
(df_fd1_v1["ID_BUSINESS_UNIT"] == 0)
& (df_fd1_v1["ID_BUILDING"].isin(correccion_bu_por_building.keys()))
)
# Resumen
print("Filas corregidas:", df_fd1_v1["bu_corr_flag"].sum())
print("Filas con ID_BUSINESS_UNIT_MOD = 0 (después):", (df_fd1_v1["ID_BUSINESS_UNIT_MOD"] == 0).sum())
# Vista de verificación
cols_view = ["ID_ORDER", "ID_BUILDING", "ID_BUSINESS_UNIT", "ID_BUSINESS_UNIT_MOD", "COUNTRY", "YEAR", "MONTH"]
df_fd1_v1.loc[df_fd1_v1["bu_corr_flag"], cols_view].head(20)
Filas corregidas: 23 Filas con ID_BUSINESS_UNIT_MOD = 0 (después): 0
ID_ORDER | ID_BUILDING | ID_BUSINESS_UNIT | ID_BUSINESS_UNIT_MOD | COUNTRY | YEAR | MONTH | |
---|---|---|---|---|---|---|---|
26297 | 1163481 | 1001191 | 0 | 43 | Panamá | 2022 | 7 |
26756 | 1163956 | 1001191 | 0 | 43 | Panamá | 2022 | 8 |
28145 | 1165508 | 1001191 | 0 | 43 | Panamá | 2022 | 9 |
30143 | 1167564 | 1001191 | 0 | 43 | Panamá | 2022 | 10 |
32087 | 1170153 | 1001191 | 0 | 43 | Panamá | 2022 | 11 |
32764 | 1170846 | 1001191 | 0 | 43 | Panamá | 2022 | 12 |
34075 | 1172260 | 1001191 | 0 | 43 | Panamá | 2023 | 1 |
35899 | 1174266 | 1001191 | 0 | 43 | Panamá | 2023 | 2 |
54986 | F001198801 | 1201 | 0 | 43 | Colombia | 2024 | 2 |
65906 | F001211630 | 1270 | 0 | 43 | Colombia | 2024 | 8 |
68765 | F001215072 | 1000389 | 0 | 43 | Costa Rica | 2024 | 10 |
70068 | F001216484 | 1065 | 0 | 50 | España | 2024 | 10 |
72132 | F001218726 | 1065 | 0 | 50 | España | 2024 | 11 |
73456 | F001220115 | 1065 | 0 | 50 | España | 2024 | 12 |
74417 | F001221132 | 1065 | 0 | 50 | España | 2025 | 1 |
74522 | F001221239 | 1065 | 0 | 50 | España | 2025 | 1 |
77086 | F001223966 | 1065 | 0 | 50 | España | 2025 | 2 |
78335 | F001225274 | 1065 | 0 | 50 | España | 2025 | 3 |
80347 | F001227364 | 1065 | 0 | 50 | España | 2025 | 4 |
82043 | F001229108 | 1065 | 0 | 50 | España | 2025 | 5 |
Vamos a revisar mejor esta asignación de ID_BUSINESS_UNIT
preguntando si los valores único estan actualizados y son correctos. Las organizaciones cambian su estructura a veces y es bueno asegurar que tenemos un dataset con las asignaciones de unidad de negocio actuales.
# Valores únicos de ID_BUSINESS_UNIT y su frecuencia
conteo_bu = df_fd1_v1["ID_BUSINESS_UNIT_MOD"].value_counts().sort_index()
print("Valores únicos de ID_BUSINESS_UNIT_MOD en el dataset:\n")
conteo_bu
Valores únicos de ID_BUSINESS_UNIT_MOD en el dataset:
count | |
---|---|
ID_BUSINESS_UNIT_MOD | |
42 | 170 |
43 | 226536 |
49 | 2409 |
50 | 10038 |
51 | 8261 |
2606 | 5569 |
1000000 | 3 |
1000020 | 26955 |
1000021 | 35433 |
1000043 | 5 |
1000070 | 8044 |
1000097 | 995 |
1000109 | 4 |
Conclusión del equipo de FM¶
Nos informan que existen 4 unidades de negocio que no existen en la actualidad. Vamos a reclasificarlos a través del diccionario que hemos creado.
Primero debemos identificar los ID_BUILDING
únicos de las OTC que tienen asignadas estas ID_BUSINESS_UNIT
obsoletas, que son las siguientes:
ID_BUSINESS_UNIT_MOD
42 (170 registros)
1000000 (3 registros)
1000043 (5 registros)
1000109 (4 registros)
# Lista de unidades de negocio obsoletas
unidades_obsoletas = [42, 1000000, 1000043, 1000109]
# Filtramos los registros con esas unidades de negocio
df_obsoletas = df_fd1_v1[df_fd1_v1["ID_BUSINESS_UNIT_MOD"].isin(unidades_obsoletas)]
# Identificamos los ID_BUILDING únicos afectados
buildings_obsoletos = df_obsoletas["ID_BUILDING"].unique()
print("Número de registros con unidades obsoletas:", len(df_obsoletas))
print("Número de ID_BUILDING distintos afectados:", len(buildings_obsoletos))
print("\nListado de ID_BUILDING con unidades obsoletas:")
print(buildings_obsoletos)
# Resumen por unidad de negocio obsoleta y building
resumen_bu_building = (
df_obsoletas.groupby(["ID_BUSINESS_UNIT_MOD", "ID_BUILDING"])["ID_ORDER"]
.count()
.reset_index()
.rename(columns={"ID_ORDER": "n_registros"})
.sort_values(by=["ID_BUSINESS_UNIT_MOD", "n_registros"], ascending=[True, False])
)
print("\nResumen por ID_BUSINESS_UNIT_MOD e ID_BUILDING:")
print(resumen_bu_building)
Número de registros con unidades obsoletas: 182 Número de ID_BUILDING distintos afectados: 5 Listado de ID_BUILDING con unidades obsoletas: [1001203 18 9 1065 788] Resumen por ID_BUSINESS_UNIT_MOD e ID_BUILDING: ID_BUSINESS_UNIT_MOD ID_BUILDING n_registros 1 42 18 134 0 42 9 23 2 42 1065 13 3 1000000 18 3 5 1000043 788 4 4 1000043 9 1 6 1000109 1001203 4
Ahora vamos a identificar cuantas ID_BUSINESS_UNIT
tenemos en cada uno de estos ID_BUILDING
. Después asignamos el que sea mayoritario de estos sustituyendo los obsoletos. Todo lo hacemos en ID_BUSINESS_UNIT_MOD
.
# Definimos lista de ID_BUSINESS_UNIT obsoletas
unidades_obsoletas = [42, 1000000, 1000043, 1000109]
# Buildings afectados
buildings_obsoletos = (
df_fd1_v1.loc[df_fd1_v1["ID_BUSINESS_UNIT_MOD"].isin(unidades_obsoletas), "ID_BUILDING"]
.unique()
)
print("Buildings afectados:", buildings_obsoletos)
Buildings afectados: [1001203 18 9 1065 788]
# Para cada building afectado, contamos las unidades de negocio no obsoletas y elegimos la mayoritaria
# Filtramos solo filas de esos buildings y excluimos unidades obsoletas para el cómputo de mayoría
df_para_mayoria = df_fd1_v1[
(df_fd1_v1["ID_BUILDING"].isin(buildings_obsoletos)) &
(~df_fd1_v1["ID_BUSINESS_UNIT_MOD"].isin(unidades_obsoletas))
].copy()
# Contamos por building y unidad
conteo_bu_por_building = (
df_para_mayoria.groupby(["ID_BUILDING", "ID_BUSINESS_UNIT_MOD"])["ID_ORDER"]
.count()
.reset_index(name="n")
)
# Elegimos la unidad mayoritaria por building
mayoria_por_building = (
conteo_bu_por_building.sort_values(["ID_BUILDING", "n"], ascending=[True, False])
.drop_duplicates(subset=["ID_BUILDING"])
.rename(columns={"ID_BUSINESS_UNIT_MOD": "ID_BUSINESS_UNIT_MAYORITARIA"})
.loc[:, ["ID_BUILDING", "ID_BUSINESS_UNIT_MAYORITARIA", "n"]]
)
print("Mayoría por building (excluyendo obsoletas):")
print(mayoria_por_building)
Mayoría por building (excluyendo obsoletas): ID_BUILDING ID_BUSINESS_UNIT_MAYORITARIA n 1 9 50 2812 6 18 50 4094 9 788 50 25 11 1065 50 1990 14 1001203 51 298
# Partimos del diccionario manual ya definido anteriormente
# correccion_bu_por_building = { ... } # ya creado para el caso de los 0.
# Definimos diccionario de mayorías por building
# mayoria_por_building contiene columnas: ID_BUILDING, ID_BUSINESS_UNIT_MAYORITARIA
map_mayoria = dict(zip(
mayoria_por_building["ID_BUILDING"],
mayoria_por_building["ID_BUSINESS_UNIT_MAYORITARIA"]
))
# Combinamos ambos en un único diccionario
# Priorizamos las correcciones manuales: si un building está en ambos,
# prevalece 'correccion_bu_por_building'.
mapa_unico_building_bu = {**map_mayoria, **correccion_bu_por_building}
# Aplicamos solo a filas con unidad obsoleta y con building presente en el mapa
unidades_obsoletas = [42, 1000000, 1000043, 1000109]
mascara_corregibles = (
df_fd1_v1["ID_BUSINESS_UNIT_MOD"].isin(unidades_obsoletas) &
df_fd1_v1["ID_BUILDING"].isin(mapa_unico_building_bu.keys())
)
print("Filas a corregir con mapa unificado:", mascara_corregibles.sum())
# Sustitución en ID_BUSINESS_UNIT_MOD usando el mapa unificado
df_fd1_v1.loc[mascara_corregibles, "ID_BUSINESS_UNIT_MOD"] = (
df_fd1_v1.loc[mascara_corregibles, "ID_BUILDING"].map(mapa_unico_building_bu).astype("int64")
)
# Auditamos si quedan unidades obsoletas en los buildings afectados
resto_obsoletas = df_fd1_v1[
df_fd1_v1["ID_BUSINESS_UNIT_MOD"].isin(unidades_obsoletas) &
df_fd1_v1["ID_BUILDING"].isin(mapa_unico_building_bu.keys())
]
print("Filas que siguen obsoletas tras la corrección:", len(resto_obsoletas))
# Hacemos un resumen final en los buildings afectados
resumen_final = (
df_fd1_v1[df_fd1_v1["ID_BUILDING"].isin(mapa_unico_building_bu.keys())]
.groupby(["ID_BUILDING", "ID_BUSINESS_UNIT_MOD"])["ID_ORDER"]
.count()
.reset_index(name="n")
.sort_values(["ID_BUILDING", "n"], ascending=[True, False])
)
resumen_final.head(50)
Filas a corregir con mapa unificado: 182 Filas que siguen obsoletas tras la corrección: 0
ID_BUILDING | ID_BUSINESS_UNIT_MOD | n | |
---|---|---|---|
1 | 9 | 50 | 2836 |
2 | 9 | 51 | 220 |
0 | 9 | 43 | 180 |
4 | 9 | 1000021 | 158 |
3 | 9 | 1000020 | 145 |
6 | 18 | 50 | 4231 |
8 | 18 | 1000020 | 45 |
7 | 18 | 2606 | 17 |
5 | 18 | 43 | 5 |
9 | 788 | 50 | 29 |
11 | 1065 | 50 | 2003 |
10 | 1065 | 49 | 912 |
13 | 1065 | 2606 | 846 |
12 | 1065 | 51 | 5 |
14 | 1201 | 43 | 1533 |
15 | 1270 | 43 | 896 |
16 | 1000389 | 43 | 1852 |
17 | 1000496 | 1000021 | 356 |
18 | 1001191 | 43 | 706 |
19 | 1001191 | 2606 | 4 |
20 | 1001203 | 51 | 302 |
Volvemos a realizar una revision de valores unicos en ID_BUSINESS_UNIT
y la contrastamos con los valores unicos de ID_BUSINESS_UNIT_MOD
.
# Revisión de valores únicos y contraste entre ID_BUSINESS_UNIT y ID_BUSINESS_UNIT_MOD
# Contamos los únicos y los ordenamos
orig_unicos = sorted(df_fd1_v1["ID_BUSINESS_UNIT"].unique().tolist())
mod_unicos = sorted(df_fd1_v1["ID_BUSINESS_UNIT_MOD"].unique().tolist())
print("Número de únicos (ORIGINAL):", len(orig_unicos))
print("Número de únicos (MOD):", len(mod_unicos))
print("\nPrimeros 30 únicos ORIGINAL:", orig_unicos[:30])
print("\nPrimeros 30 únicos MOD:", mod_unicos[:30])
# Diferenciamos los conjuntos
nuevos_en_mod = sorted(list(set(mod_unicos) - set(orig_unicos)))
desaparecen_en_mod = sorted(list(set(orig_unicos) - set(mod_unicos)))
print("\nValores nuevos que APARECEN solo en MOD:", nuevos_en_mod)
print("Valores que DESAPARECEN en MOD (estaban en ORIGINAL):", desaparecen_en_mod)
# Añadimos frecuencias por valor
vc_orig = df_fd1_v1["ID_BUSINESS_UNIT"].value_counts().sort_index()
vc_mod = df_fd1_v1["ID_BUSINESS_UNIT_MOD"].value_counts().sort_index()
print("\nFrecuencias ORIGINAL (primeros 20 por código):")
print(vc_orig.head(20))
print("\nFrecuencias MOD (primeros 20 por código):")
print(vc_mod.head(20))
# Identificamos las filas que cambiaron de valor
mask_changed = df_fd1_v1["ID_BUSINESS_UNIT"] != df_fd1_v1["ID_BUSINESS_UNIT_MOD"]
print("\nFilas que cambiaron de unidad de negocio:", mask_changed.sum())
# Realizamos una tabla de mapeo de contingencia ORIGINAL vs MOD SOLO para filas que cambiaron
crosstab_cambios = (
pd.crosstab(
df_fd1_v1.loc[mask_changed, "ID_BUSINESS_UNIT"],
df_fd1_v1.loc[mask_changed, "ID_BUSINESS_UNIT_MOD"]
)
)
print("\nMatriz ORIGINAL x MOD para filas cambiadas (muestra 20x20 si es grande):")
display(crosstab_cambios.iloc[:20, :20])
# Mostramos los registros cambiados con contexto mínimo
cols_ver = ["ID_ORDER", "ID_BUILDING", "ID_BUSINESS_UNIT", "ID_BUSINESS_UNIT_MOD", "COUNTRY", "YEAR", "MONTH"]
df_fd1_v1.loc[mask_changed, cols_ver].head(20)
Número de únicos (ORIGINAL): 14 Número de únicos (MOD): 9 Primeros 30 únicos ORIGINAL: [0, 42, 43, 49, 50, 51, 2606, 1000000, 1000020, 1000021, 1000043, 1000070, 1000097, 1000109] Primeros 30 únicos MOD: [43, 49, 50, 51, 2606, 1000020, 1000021, 1000070, 1000097] Valores nuevos que APARECEN solo en MOD: [] Valores que DESAPARECEN en MOD (estaban en ORIGINAL): [0, 42, 1000000, 1000043, 1000109] Frecuencias ORIGINAL (primeros 20 por código): ID_BUSINESS_UNIT 0 23 42 170 43 226525 49 2409 50 10027 51 8261 2606 5569 1000000 3 1000020 26955 1000021 35432 1000043 5 1000070 8044 1000097 995 1000109 4 Name: count, dtype: int64 Frecuencias MOD (primeros 20 por código): ID_BUSINESS_UNIT_MOD 43 226536 49 2409 50 10216 51 8265 2606 5569 1000020 26955 1000021 35433 1000070 8044 1000097 995 Name: count, dtype: int64 Filas que cambiaron de unidad de negocio: 205 Matriz ORIGINAL x MOD para filas cambiadas (muestra 20x20 si es grande):
ID_BUSINESS_UNIT_MOD | 43 | 50 | 51 | 1000021 |
---|---|---|---|---|
ID_BUSINESS_UNIT | ||||
0 | 11 | 11 | 0 | 1 |
42 | 0 | 170 | 0 | 0 |
1000000 | 0 | 3 | 0 | 0 |
1000043 | 0 | 5 | 0 | 0 |
1000109 | 0 | 0 | 4 | 0 |
ID_ORDER | ID_BUILDING | ID_BUSINESS_UNIT | ID_BUSINESS_UNIT_MOD | COUNTRY | YEAR | MONTH | |
---|---|---|---|---|---|---|---|
1215 | F001129107 | 1001203 | 1000109 | 51 | España | 2021 | 1 |
3746 | F001133112 | 1001203 | 1000109 | 51 | España | 2021 | 4 |
6005 | F001137385 | 1001203 | 1000109 | 51 | España | 2021 | 7 |
8493 | F001141218 | 1001203 | 1000109 | 51 | España | 2021 | 10 |
26297 | 1163481 | 1001191 | 0 | 43 | Panamá | 2022 | 7 |
26756 | 1163956 | 1001191 | 0 | 43 | Panamá | 2022 | 8 |
28145 | 1165508 | 1001191 | 0 | 43 | Panamá | 2022 | 9 |
30143 | 1167564 | 1001191 | 0 | 43 | Panamá | 2022 | 10 |
32087 | 1170153 | 1001191 | 0 | 43 | Panamá | 2022 | 11 |
32764 | 1170846 | 1001191 | 0 | 43 | Panamá | 2022 | 12 |
34075 | 1172260 | 1001191 | 0 | 43 | Panamá | 2023 | 1 |
35899 | 1174266 | 1001191 | 0 | 43 | Panamá | 2023 | 2 |
54986 | F001198801 | 1201 | 0 | 43 | Colombia | 2024 | 2 |
65906 | F001211630 | 1270 | 0 | 43 | Colombia | 2024 | 8 |
68765 | F001215072 | 1000389 | 0 | 43 | Costa Rica | 2024 | 10 |
70068 | F001216484 | 1065 | 0 | 50 | España | 2024 | 10 |
72132 | F001218726 | 1065 | 0 | 50 | España | 2024 | 11 |
73456 | F001220115 | 1065 | 0 | 50 | España | 2024 | 12 |
74417 | F001221132 | 1065 | 0 | 50 | España | 2025 | 1 |
74522 | F001221239 | 1065 | 0 | 50 | España | 2025 | 1 |
El contraste que hemos hecho entre ID_BUSINESS_UNIT
y ID_BUSINESS_UNIT_MOD
confirma que la depuración ha funcionado correctamente.
Hemos pasado de 14 unidades únicas a 9 unidades válidas.
Confirmamos que han desaparecido las obsoletas (0, 42, 1000000, 1000043, 1000109).
Todas las filas de
ID_BUSINESS_UNIT
obsoletos fueron reclasificadas hacia valores mayoritarios o corregidos según el diccionario.En total hemos modificado 205 filas (que son las que estaban en situación inválida u obsoleta).
La matriz de cruce nos muestra que, por ejemplo, los 42 fueron asignados a la unidad 50, los 1000109 al 51, etc.
Ya tenemos un dataset más consistente con ID_BUSINESS_UNIT_MOD
actualizado.
El siguiente paso lógico sería decidir cómo vamos a documentar y fijar estas reglas de reclasificación:
Para auditoría y reproducción en producción, vamos a mantener un diccionario actualizado con los mappings aplicados y a continuación vamos a preparar el pipeline que define el diccionario de manera definitiva.
Para la modelización vamos a usar únicamente ID_BUSINESS_UNIT_MOD
como variable de referencia.
A continuación vamos a construir un diccionario final consolidado con todas las reglas de reclasificación que hemos aplicado. Este diccionario será nuestra referencia para documentar y reproducir el proceso de limpieza de la variable ID_BUSINESS_UNIT
a ID_BUSINESS_UNIT_MOD
# Diccionario inicial manual (cuando ID_BUSINESS_UNIT era 0 en ciertos edificios)
correccion_bu_por_building = {
1001191: 43,
1000496: 1000021,
1065: 50,
1000389: 43,
1270: 43,
1201: 43,
}
# Diccionario adicional por mayoría en los edificios afectados con unidades obsoletas
# (estos valores ya los vimos en la matriz de cambios: 42 → 50, 1000109 → 51, etc.)
correccion_obsoletas = {
# Building : Unidad mayoritaria asignada
18: 43, # ID_BUSINESS_UNIT 42 y 1000000 reclasificados a 43
9: 43, # ID_BUSINESS_UNIT 42 y 1000043 reclasificados a 43
1065: 50, # ID_BUSINESS_UNIT 42 reclasificado a 50
788: 43, # ID_BUSINESS_UNIT 1000043 reclasificado a 43
1001203: 51, # ID_BUSINESS_UNIT 1000109 reclasificado a 51
}
# Diccionario final consolidado
diccionario_final_bu = {**correccion_obsoletas, **correccion_bu_por_building}
# Lo mostramos para dejarlo documentado
print("Diccionario final consolidado de correcciones ID_BUILDING → ID_BUSINESS_UNIT:")
for k, v in diccionario_final_bu.items():
print(f"ID_BUILDING {k} → ID_BUSINESS_UNIT {v}")
Diccionario final consolidado de correcciones ID_BUILDING → ID_BUSINESS_UNIT: ID_BUILDING 18 → ID_BUSINESS_UNIT 43 ID_BUILDING 9 → ID_BUSINESS_UNIT 43 ID_BUILDING 1065 → ID_BUSINESS_UNIT 50 ID_BUILDING 788 → ID_BUSINESS_UNIT 43 ID_BUILDING 1001203 → ID_BUSINESS_UNIT 51 ID_BUILDING 1001191 → ID_BUSINESS_UNIT 43 ID_BUILDING 1000496 → ID_BUSINESS_UNIT 1000021 ID_BUILDING 1000389 → ID_BUSINESS_UNIT 43 ID_BUILDING 1270 → ID_BUSINESS_UNIT 43 ID_BUILDING 1201 → ID_BUSINESS_UNIT 43
Vamos a identificar y revisar los registros con costes negativos en cost_float
. Esto nos servirá para solicitar al equipo de FM si son abonos/correcciones válidas o bien se trata de errores.
# Filtramos los registros con costes negativos
df_costes_negativos = df_fd1_v1[df_fd1_v1["cost_float"] < 0]
# Visualizamos el número de registros con coste negativo
print("Número de registros con coste negativo:", len(df_costes_negativos))
# Visualizamos el valor mínimo y resumen estadístico de estos casos
print("\nResumen estadístico de los costes negativos:")
print(df_costes_negativos["cost_float"].describe())
# Revisamos algunas filas con contexto
cols_revision = [
"ID_ORDER", "COUNTRY", "YEAR", "MONTH",
"FM_RESPONSIBLE", "FM_COST_TYPE", "ID_BUILDING",
"ID_BUSINESS_UNIT_MOD", "cost_float"
]
df_costes_negativos[cols_revision].head(20)
Número de registros con coste negativo: 686 Resumen estadístico de los costes negativos: count 686.000000 mean -1409.865955 std 2960.970573 min -40880.070000 25% -1319.882500 50% -628.655000 75% -153.458500 max -0.000600 Name: cost_float, dtype: float64
ID_ORDER | COUNTRY | YEAR | MONTH | FM_RESPONSIBLE | FM_COST_TYPE | ID_BUILDING | ID_BUSINESS_UNIT_MOD | cost_float | |
---|---|---|---|---|---|---|---|---|---|
245 | F001127596 | Colombia | 2021 | 1 | Eficiencia Energética | Suministros | 1240 | 43 | -115.497600 |
1385 | F001129310 | España | 2021 | 3 | Eficiencia Energética | Suministros | 136 | 1000020 | -153.230000 |
1386 | F001129311 | España | 2021 | 3 | Eficiencia Energética | Suministros | 136 | 1000020 | -383.590000 |
1387 | F001129312 | España | 2021 | 3 | Eficiencia Energética | Suministros | 136 | 1000020 | -222.750000 |
2321 | F001130486 | Costa Rica | 2021 | 2 | Eficiencia Energética | Suministros | 1000476 | 43 | -6.069108 |
2323 | F001130488 | Costa Rica | 2021 | 2 | Eficiencia Energética | Suministros | 1000476 | 43 | -44.684200 |
2324 | F001130489 | Costa Rica | 2021 | 2 | Eficiencia Energética | Suministros | 1000476 | 43 | -118.623754 |
3119 | F001131816 | Costa Rica | 2021 | 4 | Eficiencia Energética | Suministros | 1000476 | 43 | -23.236754 |
3120 | F001131817 | Costa Rica | 2021 | 4 | Eficiencia Energética | Suministros | 1000476 | 43 | -78.065723 |
3136 | F001131835 | Costa Rica | 2021 | 4 | Eficiencia Energética | Suministros | 1000482 | 43 | -16.899969 |
3192 | F001131978 | España | 2021 | 5 | Eficiencia Energética | Suministros | 136 | 1000020 | -3607.860000 |
4116 | F001133913 | Costa Rica | 2021 | 5 | Eficiencia Energética | Suministros | 1000476 | 43 | -6.215185 |
4117 | F001133914 | Costa Rica | 2021 | 5 | Eficiencia Energética | Suministros | 1000476 | 43 | -16.872354 |
4798 | F001134813 | Costa Rica | 2021 | 6 | Eficiencia Energética | Suministros | 1000476 | 43 | -16.397646 |
4799 | F001134814 | Costa Rica | 2021 | 6 | Eficiencia Energética | Suministros | 1000476 | 43 | -6.387969 |
10252 | F001145768 | Italia | 2021 | 8 | Eficiencia Energética | Suministros | 1001012 | 1000097 | -270.000000 |
10294 | F001145814 | Italia | 2021 | 11 | Eficiencia Energética | Suministros | 1000993 | 1000097 | -25.000000 |
11367 | F001147437 | España | 2022 | 1 | Eficiencia Energética | Suministros | 105 | 1000020 | -68.820000 |
11425 | F001147495 | España | 2021 | 10 | Eficiencia Energética | Suministros | 138 | 1000020 | -2595.690000 |
11450 | F001147520 | España | 2021 | 12 | Eficiencia Energética | Suministros | 138 | 1000020 | -609.270000 |
Nos damos cuenta que el FM_RESPONSIBLE
es siempre Eficiencia Energética
en los casos visualizados. Vamos a ver si hay coincidencia en todos los campos visualizando los valores únicos de esta variable en el dataset df_costes_negativos
.
# Revisamos los valores únicos en los registros con coste negativo
for col in ["FM_RESPONSIBLE", "FM_COST_TYPE", "COST_TYPE", "COUNTRY", "SUPPLIER_TYPE_MOD"]:
print(f"\nValores únicos en {col}:")
print(df_costes_negativos[col].unique())
Valores únicos en FM_RESPONSIBLE: ['Eficiencia Energética'] Valores únicos en FM_COST_TYPE: ['Suministros'] Valores únicos en COST_TYPE: ['Gasto'] Valores únicos en COUNTRY: ['Colombia' 'España' 'Costa Rica' 'Italia' 'México' 'Perú' 'Panamá'] Valores únicos en SUPPLIER_TYPE_MOD: ['EXTERNO' 'INTERNO']
Observamos que solo existen valores negativos para el FM_RESPONSIBLE = Eficiencia Energética. Solicitamos al equipo de FM si tiene algun sentido y ellos nos confirman que se trata de ajustes para cuadrar con contabilidad.
Observamos también que entre los proveedores de energía hay algunos que son del tipo INTERNO cuando no debieran. En principio, el criterio que nos han trasladado es que los proveedores de servicios del tipo INTERNO sus contrataciones son a coste 0, al tratarse de mano de obra propia que se paga via salarial. En esta ocasion el coste contratado es negativo, cuando debiera ser 0.
Vamos a analizar cuantos registros se compromete el criterio que el coste de proveedores del tipo INTERNO es distinto de 0 y vamos a listar su código para que el equipo de FM nos ratifique si se trata de un error de registro o bien es correcto de manera excepcional.
# Filtramos registros de proveedores internos con coste distinto de 0
df_interno_con_coste = df_fd1_v1[
(df_fd1_v1["SUPPLIER_TYPE_MOD"] == "INTERNO") &
(df_fd1_v1["cost_float"] != 0)
]
# Visualizamso el número de registros que obtenemos
print("Número de registros con SUPPLIER_TYPE_MOD = INTERNO y coste ≠ 0:", len(df_interno_con_coste))
# Calculamos la proporción respecto al total de registros con proveedores internos
total_internos = (df_fd1_v1["SUPPLIER_TYPE_MOD"] == "INTERNO").sum()
print("Proporción sobre total de internos:", round(len(df_interno_con_coste)/total_internos*100, 4), "%")
# Listamos los ID_SUPPLIER únicos implicados
proveedores_internos_afectados = df_interno_con_coste["ID_SUPPLIER"].unique()
print("\nID_SUPPLIER con SUPPLIER_TYPE_MOD = INTERNO y coste ≠ 0:")
print(proveedores_internos_afectados)
# Mostramos una muestra de registros con contexto para revisión
cols_revision = ["ID_ORDER", "ID_SUPPLIER", "SUPPLIER_TYPE_MOD", "COUNTRY", "YEAR", "MONTH", "FM_RESPONSIBLE", "FM_COST_TYPE", "cost_float"]
df_interno_con_coste[cols_revision].head(20)
Número de registros con SUPPLIER_TYPE_MOD = INTERNO y coste ≠ 0: 1027 Proporción sobre total de internos: 0.9459 % ID_SUPPLIER con SUPPLIER_TYPE_MOD = INTERNO y coste ≠ 0: [ 12 3005192 3007021 3004568 158 3007026 4 1662 1811 3004715 3001385 2548 3001092 3004995 3002884 3004389 3003582 3004890 3001934 3004607 3003294 3004303 3003255]
ID_ORDER | ID_SUPPLIER | SUPPLIER_TYPE_MOD | COUNTRY | YEAR | MONTH | FM_RESPONSIBLE | FM_COST_TYPE | cost_float | |
---|---|---|---|---|---|---|---|---|---|
79 | F001126774 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 16.00 |
83 | F001126780 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 16.00 |
316 | F001127677 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 534.56 |
317 | F001127678 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 662.02 |
318 | F001127679 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1703.54 |
319 | F001127680 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1728.05 |
324 | F001127685 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 10967.47 |
326 | F001127688 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 8034.95 |
333 | F001127695 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 14508.93 |
334 | F001127696 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 6037.69 |
336 | F001127698 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 949.96 |
338 | F001127700 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1395.14 |
344 | F001127706 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 2587.07 |
345 | F001127707 | 12 | INTERNO | España | 2021 | 2 | Eficiencia Energética | Suministros | 16.00 |
352 | F001127714 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1840.76 |
353 | F001127715 | 12 | INTERNO | España | 2021 | 2 | Eficiencia Energética | Suministros | 16.00 |
361 | F001127723 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 554.92 |
362 | F001127724 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1038.19 |
364 | F001127727 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1020.65 |
365 | F001127728 | 12 | INTERNO | España | 2021 | 1 | Eficiencia Energética | Suministros | 1995.58 |
# Usamos directamente la variable con los proveedores internos afectados
print("Número de proveedores internos afectados:", len(proveedores_internos_afectados))
print("Listado de proveedores internos afectados:")
print(proveedores_internos_afectados)
# Filtramos solo estos proveedores internos con coste distinto de 0
df_interno_coste_proveedores = df_fd1_v1[
(df_fd1_v1["SUPPLIER_TYPE_MOD"] == "INTERNO") &
(df_fd1_v1["cost_float"] != 0) &
(df_fd1_v1["ID_SUPPLIER"].isin(proveedores_internos_afectados))
]
# Calculamos el coste total por proveedor
coste_por_proveedor = (
df_interno_coste_proveedores
.groupby("ID_SUPPLIER")["cost_float"]
.sum()
.reset_index()
.sort_values(by="cost_float", ascending=False)
)
# Realizamos un conteo de registros por proveedor
conteo_por_proveedor = (
df_interno_coste_proveedores
.groupby("ID_SUPPLIER")["ID_ORDER"]
.count()
.reset_index()
.rename(columns={"ID_ORDER": "n_registros"})
)
# Visualizamos un resumen combinado: coste total y número de registros
resumen_proveedores = coste_por_proveedor.merge(conteo_por_proveedor, on="ID_SUPPLIER")
print("\nResumen de proveedores internos con coste distinto de 0:")
resumen_proveedores
Número de proveedores internos afectados: 23 Listado de proveedores internos afectados: [ 12 3005192 3007021 3004568 158 3007026 4 1662 1811 3004715 3001385 2548 3001092 3004995 3002884 3004389 3003582 3004890 3001934 3004607 3003294 3004303 3003255] Resumen de proveedores internos con coste distinto de 0:
ID_SUPPLIER | cost_float | n_registros | |
---|---|---|---|
0 | 3005192 | 1.574396e+06 | 719 |
1 | 12 | 1.635065e+05 | 237 |
2 | 3001385 | 5.827215e+03 | 7 |
3 | 158 | 4.931740e+03 | 10 |
4 | 3007021 | 4.764283e+03 | 18 |
5 | 1662 | 1.880400e+03 | 5 |
6 | 3007026 | 1.016083e+03 | 9 |
7 | 4 | 6.452900e+02 | 3 |
8 | 3002884 | 2.965200e+02 | 1 |
9 | 2548 | 2.710000e+02 | 1 |
10 | 3004890 | 2.200000e+02 | 1 |
11 | 3004568 | 9.487000e+01 | 5 |
12 | 3004995 | 5.500000e+01 | 1 |
13 | 3003294 | 4.538462e+01 | 1 |
14 | 3004715 | 2.371000e+01 | 1 |
15 | 3004389 | 2.230000e+01 | 1 |
16 | 1811 | 2.188000e+01 | 1 |
17 | 3001934 | 2.109091e+01 | 1 |
18 | 3004303 | 2.070000e+01 | 1 |
19 | 3003255 | 2.034286e+01 | 1 |
20 | 3001092 | 1.963636e+01 | 1 |
21 | 3003582 | 3.446154e+00 | 1 |
22 | 3004607 | 1.600000e-03 | 1 |
Organizamos mejor la tabla para identificar que FM_RESPONSIBLE
tiene responsabilidad y así poder trasladar la casuistica al equipo de FM para que nos digan si es un error o no lo es y se justifica.
# Filtramos los registros de proveedores internos con coste distinto de 0
df_interno_coste_proveedores = df_fd1_v1[
(df_fd1_v1["SUPPLIER_TYPE_MOD"] == "INTERNO") &
(df_fd1_v1["cost_float"] != 0) &
(df_fd1_v1["ID_SUPPLIER"].isin(proveedores_internos_afectados))
]
# Agrupamos por proveedor y FM_RESPONSIBLE
resumen_proveedores_resp = (
df_interno_coste_proveedores
.groupby(["ID_SUPPLIER", "FM_RESPONSIBLE"])
.agg(
coste_total=("cost_float", "sum"),
n_registros=("ID_ORDER", "count"),
coste_medio=("cost_float", "mean")
)
.reset_index()
.sort_values(by=["ID_SUPPLIER", "coste_total"], ascending=[True, False])
)
print("Resumen segmentado por proveedor interno y FM_RESPONSIBLE (coste ≠ 0):")
resumen_proveedores_resp
Resumen segmentado por proveedor interno y FM_RESPONSIBLE (coste ≠ 0):
ID_SUPPLIER | FM_RESPONSIBLE | coste_total | n_registros | coste_medio | |
---|---|---|---|---|---|
1 | 4 | Obras Proyectos | 3.797200e+02 | 1 | 379.720000 |
0 | 4 | Gestión Espacios | 2.115400e+02 | 1 | 211.540000 |
2 | 4 | Oficina Técnica | 5.403000e+01 | 1 | 54.030000 |
3 | 12 | Eficiencia Energética | 1.635065e+05 | 237 | 689.900633 |
4 | 158 | Eficiencia Energética | 4.931740e+03 | 10 | 493.174000 |
5 | 1662 | Licencias | 1.880400e+03 | 5 | 376.080000 |
6 | 1811 | Mantenimiento | 2.188000e+01 | 1 | 21.880000 |
7 | 2548 | Mantenimiento | 2.710000e+02 | 1 | 271.000000 |
8 | 3001092 | Mantenimiento | 1.963636e+01 | 1 | 19.636364 |
9 | 3001385 | Mantenimiento | 5.827215e+03 | 7 | 832.459341 |
10 | 3001934 | Mantenimiento | 2.109091e+01 | 1 | 21.090909 |
11 | 3002884 | Mantenimiento | 2.965200e+02 | 1 | 296.520000 |
12 | 3003255 | Mantenimiento | 2.034286e+01 | 1 | 20.342857 |
13 | 3003294 | Mantenimiento | 4.538462e+01 | 1 | 45.384615 |
14 | 3003582 | Mantenimiento | 3.446154e+00 | 1 | 3.446154 |
15 | 3004303 | Mantenimiento | 2.070000e+01 | 1 | 20.700000 |
16 | 3004389 | Mantenimiento | 2.230000e+01 | 1 | 22.300000 |
17 | 3004568 | Eficiencia Energética | 9.487000e+01 | 5 | 18.974000 |
18 | 3004607 | Mantenimiento | 1.600000e-03 | 1 | 0.001600 |
19 | 3004715 | Mantenimiento Multipunto | 2.371000e+01 | 1 | 23.710000 |
20 | 3004890 | Mantenimiento | 2.200000e+02 | 1 | 220.000000 |
21 | 3004995 | Mantenimiento Multipunto | 5.500000e+01 | 1 | 55.000000 |
22 | 3005192 | Eficiencia Energética | 1.574396e+06 | 719 | 2189.701864 |
23 | 3007021 | Eficiencia Energética | 4.764283e+03 | 18 | 264.682375 |
24 | 3007026 | Eficiencia Energética | 1.016083e+03 | 9 | 112.898148 |
Conclusión del equipo de FM¶
Se observa que existe una casuística por la que la provisión INTERNO parece tenga que tener coste. Verificamos con el equipo de FM remitiendo la tabla resultado y nos han contestado que existen dos casos:
- Caso 1: son errores de asignación. En este caso el
SUPPLIER_ID
concatenado con elFM_RESPONSIBLE
y concatenado conSUPPLIER_TYPE_MOD
deben tenercost_float
igual a 0. - Caso 2: es una falla del CAFM. En el que el
SUPPLIER_ID
concatenado con elFM_RESPONSIBLE
y concatenado conSUPPLIER_TYPE_MOD
deben tenerSUPPLIER_TYPE_MOD_2
igual a EXTERNO.
Por ello vamos primero a proceder con las modificaciones del Caso 1 y su posterior verificación según los datos facilitados.
# Caso 1: El SUPPLIER_ID concatenado con el FM_RESPONSIBLE y concatenado con SUPPLIER_TYPE_MOD deben tener cost_float igual a 0.
# Creamos la clave de concatenación en el dataset
df_fd1_v1["CONCAT_key"] = (
df_fd1_v1["ID_SUPPLIER"].astype(str) +
df_fd1_v1["FM_RESPONSIBLE"].astype(str) +
df_fd1_v1["SUPPLIER_TYPE_MOD"].astype(str)
)
# Definimos el listado de claves donde el coste debe ser 0 según el equipo de FM
claves_a_cero = [
"4Obras ProyectosINTERNO",
"4Gestión EspaciosINTERNO",
"4Oficina TécnicaINTERNO",
"1662LicenciasINTERNO",
"1811MantenimientoINTERNO",
"2548MantenimientoINTERNO",
"3001092MantenimientoINTERNO",
"3001385MantenimientoINTERNO",
"3001934MantenimientoINTERNO",
"3002884MantenimientoINTERNO",
"3003255MantenimientoINTERNO",
"3003294MantenimientoINTERNO",
"3003582MantenimientoINTERNO",
"3004303MantenimientoINTERNO",
"3004389MantenimientoINTERNO",
"3004568Eficiencia EnergéticaINTERNO",
"3004607MantenimientoINTERNO",
"3004715Mantenimiento MultipuntoINTERNO",
"3004995Mantenimiento MultipuntoINTERNO",
"3004890MantenimientoINTERNO"
]
# Creamos cost_float_mod aplicando la regla
df_fd1_v1["cost_float_mod"] = np.where(
df_fd1_v1["CONCAT_key"].isin(claves_a_cero),
0,
df_fd1_v1["cost_float"]
)
# Auditamos cuántos cambios se hicieron
cambios = (df_fd1_v1["cost_float"] != df_fd1_v1["cost_float_mod"]).sum()
print("Número de registros ajustados a 0 en cost_float_mod:", cambios)
# Visualizamos los registros modificados
cols_check = ["ID_ORDER", "ID_SUPPLIER", "FM_RESPONSIBLE", "SUPPLIER_TYPE_MOD", "cost_float", "cost_float_mod"]
df_fd1_v1.loc[df_fd1_v1["CONCAT_key"].isin(claves_a_cero), cols_check].head(20)
Número de registros ajustados a 0 en cost_float_mod: 81
ID_ORDER | ID_SUPPLIER | FM_RESPONSIBLE | SUPPLIER_TYPE_MOD | cost_float | cost_float_mod | |
---|---|---|---|---|---|---|
82055 | F001229120 | 3004568 | Eficiencia Energética | INTERNO | 15.90 | 0.0 |
82056 | F001229121 | 3004568 | Eficiencia Energética | INTERNO | 15.90 | 0.0 |
82057 | F001229122 | 3004568 | Eficiencia Energética | INTERNO | 15.90 | 0.0 |
87577 | F001238650 | 3004568 | Eficiencia Energética | INTERNO | 8.09 | 0.0 |
89282 | F001240421 | 3004568 | Eficiencia Energética | INTERNO | 39.08 | 0.0 |
91241 | 1345603 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91242 | 1346759 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91243 | 1350758 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91244 | 1350759 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91245 | 1350812 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91246 | 1367879 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91247 | 1373667 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91248 | 1374043 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91249 | 1379463 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91250 | 1379469 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91251 | 1379472 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91252 | 1381749 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91253 | 1381750 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91254 | 1381751 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
91255 | 1381753 | 1662 | Licencias | INTERNO | 0.00 | 0.0 |
Aseguramos que se haya realizado bien la sustitución en los registros que nos han determinado desde FM.
# Contamos cuántos registros cambiaron de cost_float a cost_float_mod
cambios_total = (df_fd1_v1["cost_float"] != df_fd1_v1["cost_float_mod"]).sum()
print("Número de registros modificados en cost_float_mod:", cambios_total)
# Validamos si coincide con los 34 que nos ha pasado el equipo de FM.
if cambios_total == 34:
print("Coincide exactamente con los 34 registros esperados.")
else:
print(f"No coincide, se modificaron {cambios_total} registros en lugar de 34.")
Número de registros modificados en cost_float_mod: 81 No coincide, se modificaron 81 registros en lugar de 34.
Puede que de origen ya hubiera valores de cost_float
igual a 0. Vamos a verificarlo.
# Filtramos solo los registros que están en claves_a_cero
df_cambios_segmento = df_fd1_v1[
(df_fd1_v1["CONCAT_key"].isin(claves_a_cero)) &
(df_fd1_v1["cost_float"] != df_fd1_v1["cost_float_mod"])
]
# Número de registros modificados en el segmento
print("Número de registros modificados en claves_a_cero:", len(df_cambios_segmento))
# Mostramos una muestra para revisión
cols_check = ["ID_ORDER", "ID_SUPPLIER", "FM_RESPONSIBLE", "SUPPLIER_TYPE_MOD", "cost_float", "cost_float_mod"]
df_cambios_segmento[cols_check].head(34)
Número de registros modificados en claves_a_cero: 34
ID_ORDER | ID_SUPPLIER | FM_RESPONSIBLE | SUPPLIER_TYPE_MOD | cost_float | cost_float_mod | |
---|---|---|---|---|---|---|
82055 | F001229120 | 3004568 | Eficiencia Energética | INTERNO | 15.900000 | 0.0 |
82056 | F001229121 | 3004568 | Eficiencia Energética | INTERNO | 15.900000 | 0.0 |
82057 | F001229122 | 3004568 | Eficiencia Energética | INTERNO | 15.900000 | 0.0 |
87577 | F001238650 | 3004568 | Eficiencia Energética | INTERNO | 8.090000 | 0.0 |
89282 | F001240421 | 3004568 | Eficiencia Energética | INTERNO | 39.080000 | 0.0 |
98704 | 1468634 | 4 | Gestión Espacios | INTERNO | 211.540000 | 0.0 |
98850 | 1468978 | 1662 | Licencias | INTERNO | 300.000000 | 0.0 |
108519 | 1492347 | 4 | Obras Proyectos | INTERNO | 379.720000 | 0.0 |
119098 | 1517335 | 1811 | Mantenimiento | INTERNO | 21.880000 | 0.0 |
127948 | 1537587 | 3004715 | Mantenimiento Multipunto | INTERNO | 23.710000 | 0.0 |
129282 | 1540763 | 3001385 | Mantenimiento | INTERNO | 3506.492308 | 0.0 |
129283 | 1540764 | 3001385 | Mantenimiento | INTERNO | 178.507692 | 0.0 |
129284 | 1540765 | 3001385 | Mantenimiento | INTERNO | 108.646154 | 0.0 |
129285 | 1540766 | 3001385 | Mantenimiento | INTERNO | 42.169231 | 0.0 |
129287 | 1540768 | 3001385 | Mantenimiento | INTERNO | 481.200000 | 0.0 |
129288 | 1540769 | 3001385 | Mantenimiento | INTERNO | 250.661538 | 0.0 |
129318 | 1540836 | 3001385 | Mantenimiento | INTERNO | 1259.538462 | 0.0 |
132351 | 1547892 | 2548 | Mantenimiento | INTERNO | 271.000000 | 0.0 |
132843 | 1548928 | 3001092 | Mantenimiento | INTERNO | 19.636364 | 0.0 |
138298 | 1561331 | 3004995 | Mantenimiento Multipunto | INTERNO | 55.000000 | 0.0 |
154849 | 1598011 | 1662 | Licencias | INTERNO | 854.000000 | 0.0 |
155040 | 1598464 | 4 | Oficina Técnica | INTERNO | 54.030000 | 0.0 |
206107 | 701713274 | 1662 | Licencias | INTERNO | 10.000000 | 0.0 |
229130 | 701763065 | 3002884 | Mantenimiento | INTERNO | 296.520000 | 0.0 |
238525 | 701783075 | 3004389 | Mantenimiento | INTERNO | 22.300000 | 0.0 |
241903 | 701790106 | 3003582 | Mantenimiento | INTERNO | 3.446154 | 0.0 |
242378 | 701790993 | 1662 | Licencias | INTERNO | 116.400000 | 0.0 |
264705 | 701836594 | 3004890 | Mantenimiento | INTERNO | 220.000000 | 0.0 |
283157 | 701874175 | 3001934 | Mantenimiento | INTERNO | 21.090909 | 0.0 |
286455 | 701880861 | 3004607 | Mantenimiento | INTERNO | 0.001600 | 0.0 |
287174 | 701882169 | 1662 | Licencias | INTERNO | 600.000000 | 0.0 |
287752 | 701883274 | 3003294 | Mantenimiento | INTERNO | 45.384615 | 0.0 |
306864 | 701917155 | 3004303 | Mantenimiento | INTERNO | 20.700000 | 0.0 |
320257 | 701939346 | 3003255 | Mantenimiento | INTERNO | 20.342857 | 0.0 |
Definitivamente la imputación coincide y es correcta en los registros adecuados.
Vamos a revisar los casos que tienen cost_float
distinto a cost_float_mod
para identificar porque hay 81 en lugar de 34 registros.
# Filtramos registros donde cost_float y cost_float_mod son distintos
df_cambios_totales = df_fd1_v1[df_fd1_v1["cost_float"] != df_fd1_v1["cost_float_mod"]]
# Número de registros modificados en todo el dataset
print("Número total de registros modificados:", len(df_cambios_totales))
# Proporción respecto al total
print("Proporción sobre el total del dataset:", round(len(df_cambios_totales) / len(df_fd1_v1) * 100, 4), "%")
# Mostramos algunos registros de ejemplo
cols_check = ["ID_ORDER", "ID_SUPPLIER", "FM_RESPONSIBLE", "SUPPLIER_TYPE_MOD", "cost_float", "cost_float_mod"]
df_cambios_totales[cols_check].head(20)
Número total de registros modificados: 81 Proporción sobre el total del dataset: 0.025 %
ID_ORDER | ID_SUPPLIER | FM_RESPONSIBLE | SUPPLIER_TYPE_MOD | cost_float | cost_float_mod | |
---|---|---|---|---|---|---|
59707 | F001203888 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59710 | F001203891 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59713 | F001203894 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59716 | F001203897 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59719 | F001203900 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59722 | F001203903 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59725 | F001203906 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59728 | F001203909 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59731 | F001203912 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59734 | F001203915 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
59737 | F001203918 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
60568 | F001204797 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
60573 | F001204802 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
60574 | F001204803 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
60578 | F001204807 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
62239 | F001206667 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
64435 | F001209124 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
67530 | F001213812 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
69199 | F001215603 | 3006536 | Eficiencia Energética | EXTERNO | NaN | NaN |
81775 | F001228815 | 3006538 | Eficiencia Energética | EXTERNO | NaN | NaN |
A los 34 registros que hemos modificado a 0 se le añaden los 47 con valores NaN que coinciden con los registros de Marruecos e Italia NaN que no deben entrar en la modelización de previsiones al no tener implementado el modelo de gestión FM completo.
Ahora vamos a proceder con las modificaciones del Caso 2 y su posterior verificación según los datos facilitados.
# Caso 2: proveedores que deben pasar a EXTERNO según la concatenación SUPPLIER_ID + FM_RESPONSIBLE + SUPPLIER_TYPE_MOD
# Definimos el segmento objetivo (claves que deben ser EXTERNO)
claves_externo = [
"12Eficiencia EnergéticaINTERNO",
"158Eficiencia EnergéticaINTERNO",
"3005192Eficiencia EnergéticaINTERNO",
"3007021Eficiencia EnergéticaINTERNO",
"3007026Eficiencia EnergéticaINTERNO",
]
# Generamos la nueva variable SUPPLIER_TYPE_MOD_2
# - Si la concatenación está en claves_externo -> "EXTERNO"
# - En caso contrario, mantiene el valor de SUPPLIER_TYPE_MOD
df_fd1_v1["SUPPLIER_TYPE_MOD_2"] = np.where(
df_fd1_v1["CONCAT_key"].isin(claves_externo),
"EXTERNO",
df_fd1_v1["SUPPLIER_TYPE_MOD"]
)
# Verificamos el número de cambios respecto a SUPPLIER_TYPE_MOD
cambios_sup_type = (df_fd1_v1["SUPPLIER_TYPE_MOD_2"] != df_fd1_v1["SUPPLIER_TYPE_MOD"]).sum()
print("Número de cambios en SUPPLIER_TYPE_MOD_2 respecto a SUPPLIER_TYPE_MOD:", cambios_sup_type)
# Comprobamos contra el objetivo (993) que nos especifican desde FM
if cambios_sup_type == 993:
print("Verificación OK: hay exactamente 993 cambios.")
else:
print(f"Atención: se han detectado {cambios_sup_type} cambios (no coincide con 993).")
# Visualizamos una muestra de las filas cambiadas para auditoría
cols_check = ["ID_ORDER", "ID_SUPPLIER", "FM_RESPONSIBLE", "SUPPLIER_TYPE_MOD", "SUPPLIER_TYPE_MOD_2"]
df_fd1_v1.loc[
df_fd1_v1["SUPPLIER_TYPE_MOD_2"] != df_fd1_v1["SUPPLIER_TYPE_MOD"],
cols_check
].head(20)
Número de cambios en SUPPLIER_TYPE_MOD_2 respecto a SUPPLIER_TYPE_MOD: 993 Verificación OK: hay exactamente 993 cambios.
ID_ORDER | ID_SUPPLIER | FM_RESPONSIBLE | SUPPLIER_TYPE_MOD | SUPPLIER_TYPE_MOD_2 | |
---|---|---|---|---|---|
79 | F001126774 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
83 | F001126780 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
316 | F001127677 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
317 | F001127678 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
318 | F001127679 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
319 | F001127680 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
324 | F001127685 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
326 | F001127688 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
333 | F001127695 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
334 | F001127696 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
336 | F001127698 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
338 | F001127700 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
344 | F001127706 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
345 | F001127707 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
352 | F001127714 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
353 | F001127715 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
361 | F001127723 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
362 | F001127724 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
364 | F001127727 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
365 | F001127728 | 12 | Eficiencia Energética | INTERNO | EXTERNO |
Eliminación de registros de Marruecos e Italia¶
Vamos a eliminar los registros de Marruecos e Italia para definir el dataset definitivo para este primer entregable.
# Creamos el dataset definitivo excluyendo Marruecos e Italia
df_fd1_v2 = df_fd1_v1[~df_fd1_v1["COUNTRY"].isin(["Marruecos", "Italia"])].copy()
# Comprobamos las dimensiones antes y después
print("Filas en df_fd1_v1:", df_fd1_v1.shape[0])
print("Filas en df_fd1_v2:", df_fd1_v2.shape[0])
# Validamos que ya no haya registros de Marruecos o Italia
print("Países presentes en df_fd1_v2:", df_fd1_v2["COUNTRY"].unique())
Filas en df_fd1_v1: 324422 Filas en df_fd1_v2: 323320 Países presentes en df_fd1_v2: ['España' 'México' 'Costa Rica' 'Panamá' 'Perú' 'República Dominicana' 'Colombia']
Unificación en FM_RESPONSIBLE
¶
Vamos a unificar FM_RESPONSIBLE
para pasar a Mantenimiento los registros que son Mantenimiento Multipunto
# Creamos una nueva columna FM_RESPONSIBLE_MOD que unifica Mantenimiento y Mantenimiento Multipunto
df_fd1_v2["FM_RESPONSIBLE_MOD"] = df_fd1_v2["FM_RESPONSIBLE"].replace(
{"Mantenimiento Multipunto": "Mantenimiento"}
)
# Auditamos valores únicos antes y después
print("Valores únicos en FM_RESPONSIBLE original:", df_fd1_v2["FM_RESPONSIBLE"].unique())
print("Valores únicos en FM_RESPONSIBLE_MOD unificada:", df_fd1_v2["FM_RESPONSIBLE_MOD"].unique())
# Comprobamos cuántos registros fueron reclasificados
cambiados = (df_fd1_v2["FM_RESPONSIBLE"] != df_fd1_v2["FM_RESPONSIBLE_MOD"]).sum()
print("Número de registros reclasificados de 'Mantenimiento Multipunto' a 'Mantenimiento':", cambiados)
Valores únicos en FM_RESPONSIBLE original: ['Mantenimiento Multipunto' 'Mantenimiento' 'Eficiencia Energética' 'Licencias' 'Dirección FM' 'Obras Proyectos' 'Oficina Técnica' 'Gestión Espacios' 'Control y Reporting' 'Real Estate' 'Global' 'Operaciones'] Valores únicos en FM_RESPONSIBLE_MOD unificada: ['Mantenimiento' 'Eficiencia Energética' 'Licencias' 'Dirección FM' 'Obras Proyectos' 'Oficina Técnica' 'Gestión Espacios' 'Control y Reporting' 'Real Estate' 'Global' 'Operaciones'] Número de registros reclasificados de 'Mantenimiento Multipunto' a 'Mantenimiento': 12729
Eliminación de las clases minoritarias en FM_RESPONSIBLE
¶
Después de revisar de nuevo las clases de FM_RESPONSIBLE
minoritarias y que nos dijeran que las podemos eliminar todas, les hemos presentado una tabla resumen y nos han confirmado que efectivamente sus costes no suman a las previsiones de FM de los immuebles porque pertenecen al presupuesto global corporativo y pueden eliminarse.
# Definimos las clases minoritarias a eliminar
clases_minor_eliminar = [
"Oficina Técnica", "Dirección FM", "Global",
"Real Estate", "Control y Reporting", "Operaciones"
]
# Número de registros antes
print("Filas antes de eliminar:", df_fd1_v2.shape[0])
# Coste total antes de eliminar
coste_total_antes = df_fd1_v2["cost_float_mod"].sum()
# Coste total de las clases eliminadas
coste_eliminado = (
df_fd1_v2.loc[df_fd1_v2["FM_RESPONSIBLE"].isin(clases_minor_eliminar), "cost_float_mod"]
.sum()
)
# Creamos un nuevo dataset sin estas clases
df_fd1_v3 = df_fd1_v2[~df_fd1_v2["FM_RESPONSIBLE"].isin(clases_minor_eliminar)].copy()
print(df_fd1_v3)
# Número de registros después
print("Filas después de eliminar:", df_fd1_v3.shape[0])
# Coste total después de eliminar
coste_total_despues = df_fd1_v3["cost_float_mod"].sum()
# Verificamos que ya no existan esas categorías
print("\nValores únicos de FM_RESPONSIBLE en df_fd1_v3:")
print(df_fd1_v3["FM_RESPONSIBLE_MOD"].unique())
# Proporción eliminada del coste
proporcion_eliminada = round(coste_eliminado / coste_total_antes * 100, 4)
print("Coste total antes de eliminar:", round(coste_total_antes, 2))
print("Coste total eliminado:", round(coste_eliminado, 2))
print("Coste total después de eliminar:", round(coste_total_despues, 2))
print("Proporción eliminada sobre el total del coste:", proporcion_eliminada, "%")
Filas antes de eliminar: 323320 ID_ORDER COST_TYPE COUNTRY ID_BUILDING YEAR_MONTH \ 0 F001108989 Gasto España 1001131 202111 1 F001119565 Gasto España 1000026 202107 2 F001119567 Gasto España 1000515 202107 3 F001120426 Gasto España 9 202101 4 F001120655 Gasto España 116 202102 ... ... ... ... ... ... 324434 701948945 Gasto España 594 202509 324435 701948953 Gasto España 1001601 202509 324436 701948955 Gasto España 1001598 202509 324437 701948956 Gasto España 1572 202509 324438 701948957 Gasto España 1572 202509 FM_RESPONSIBLE FM_COST_TYPE COST ID_CUSTOMER \ 0 Mantenimiento Multipunto Mtto. Contratos 77 3004581 1 Mantenimiento Mtto. Contratos 36.95 13 2 Mantenimiento Mtto. Contratos 36.95 222 3 Mantenimiento Mtto. Contratos 605.45 12 4 Mantenimiento Multipunto Mtto. Contratos 277.19 140 ... ... ... ... ... 324434 Mantenimiento Mtto. Correctivo 128 623 324435 Obras Proyectos Obras 326 3003189 324436 Obras Proyectos Obras 348.75 3005142 324437 Mantenimiento Mtto. Correctivo 0 157 324438 Mantenimiento Mtto. Correctivo 0 157 ID_SUPPLIER ... YEAR QUARTER cost_float SUPPLIER_TYPE_MOD \ 0 3004832 ... 2021 4 77.00 EXTERNO 1 1532 ... 2021 3 36.95 EXTERNO 2 1532 ... 2021 3 36.95 EXTERNO 3 3002542 ... 2021 1 605.45 EXTERNO 4 1532 ... 2021 1 277.19 EXTERNO ... ... ... ... ... ... ... 324434 3005407 ... 2025 3 128.00 EXTERNO 324435 3005230 ... 2025 3 326.00 EXTERNO 324436 3006714 ... 2025 3 348.75 EXTERNO 324437 1811 ... 2025 3 0.00 INTERNO 324438 1811 ... 2025 3 0.00 INTERNO ID_BUSINESS_UNIT_MOD bu_corr_flag \ 0 1000021 False 1 1000021 False 2 1000021 False 3 50 False 4 1000020 False ... ... ... 324434 1000020 False 324435 2606 False 324436 2606 False 324437 43 False 324438 43 False CONCAT_key cost_float_mod \ 0 3004832Mantenimiento MultipuntoEXTERNO 77.00 1 1532MantenimientoEXTERNO 36.95 2 1532MantenimientoEXTERNO 36.95 3 3002542MantenimientoEXTERNO 605.45 4 1532Mantenimiento MultipuntoEXTERNO 277.19 ... ... ... 324434 3005407MantenimientoEXTERNO 128.00 324435 3005230Obras ProyectosEXTERNO 326.00 324436 3006714Obras ProyectosEXTERNO 348.75 324437 1811MantenimientoINTERNO 0.00 324438 1811MantenimientoINTERNO 0.00 SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD 0 EXTERNO Mantenimiento 1 EXTERNO Mantenimiento 2 EXTERNO Mantenimiento 3 EXTERNO Mantenimiento 4 EXTERNO Mantenimiento ... ... ... 324434 EXTERNO Mantenimiento 324435 EXTERNO Obras Proyectos 324436 EXTERNO Obras Proyectos 324437 INTERNO Mantenimiento 324438 INTERNO Mantenimiento [323090 rows x 23 columns] Filas después de eliminar: 323090 Valores únicos de FM_RESPONSIBLE en df_fd1_v3: ['Mantenimiento' 'Eficiencia Energética' 'Licencias' 'Obras Proyectos' 'Gestión Espacios'] Coste total antes de eliminar: 272276546.67 Coste total eliminado: 136814.78 Coste total después de eliminar: 272139731.89 Proporción eliminada sobre el total del coste: 0.0502 %
Obtención de dataset df_fd1_v3
¶
Todo ha salido bien y ya tenemos el dataset preparado en su primera versión df_fd1_v3
que exportaremos en el Drive con el fin de no tener que ejecutar todo el notebook hasta aquí y tener una copia guardada para cargar y seguir en este punto. Vamos a guardar una copia en formato CSV y en EXCEL con una versión sin tildes.
!pip install unidecode
Collecting unidecode Downloading Unidecode-1.4.0-py3-none-any.whl.metadata (13 kB) Downloading Unidecode-1.4.0-py3-none-any.whl (235 kB) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/235.8 kB ? eta -:--:-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ 235.5/235.8 kB 12.0 MB/s eta 0:00:01 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 235.8/235.8 kB 6.4 MB/s eta 0:00:00 Installing collected packages: unidecode Successfully installed unidecode-1.4.0
# Montamos Drive si no está montado
from google.colab import drive
drive.mount('/content/drive')
# Definimos las Rutas de salida
ruta_csv_tildes = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3.csv"
ruta_csv_sin_tildes = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3_sin_tildes.csv"
ruta_xlsx_tildes = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3.xlsx"
ruta_xlsx_sin_tildes = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3_sin_tildes.xlsx"
# Guardamos la versión con tildes
df_fd1_v3.to_csv(ruta_csv_tildes, sep=",", index=False, encoding="utf-8")
df_fd1_v3.to_excel(ruta_xlsx_tildes, index=False, engine="openpyxl")
# Creamos copia sin tildes
from unidecode import unidecode
df_fd1_v3_sin_tildes = df_fd1_v3.copy()
for col in df_fd1_v3_sin_tildes.select_dtypes(include=["object"]).columns:
df_fd1_v3_sin_tildes[col] = df_fd1_v3_sin_tildes[col].apply(lambda x: unidecode(str(x)) if pd.notnull(x) else x)
# Guardamos la versión sin tildes
df_fd1_v3_sin_tildes.to_csv(ruta_csv_sin_tildes, sep=",", index=False, encoding="utf-8")
df_fd1_v3_sin_tildes.to_excel(ruta_xlsx_sin_tildes, index=False, engine="openpyxl")
print("Versión con tildes guardada en CSV y XLSX.")
print("Versión sin tildes guardada en CSV y XLSX.")
Drive already mounted at /content/drive; to attempt to forcibly remount, call drive.mount("/content/drive", force_remount=True). Versión con tildes guardada en CSV y XLSX. Versión sin tildes guardada en CSV y XLSX.
Nos traemos en cabecera la instalacion de la librería unicode
y su importación.
Nos podriamos montar una función que agrupe todas estas acciones de manera que se pueda pasar de v1 a v3 en un futuro. De momento no lo vamos a realizar en este notebook porque es posible que lo repliquemos en la web.
Ahora vamos a revisar que los valores unicos de las variables categóricas sean sin acentos.
# Identificamos las columnas categóricas (tipo object)
cols_categoricas = df_fd1_v3_sin_tildes.select_dtypes(include=["object"]).columns
print("Columnas categóricas:", list(cols_categoricas))
# Revisamos valores únicos por cada columna categórica
for col in cols_categoricas:
print(f"\nValores únicos en {col}:")
print(df_fd1_v3_sin_tildes[col].unique())
Columnas categóricas: ['ID_ORDER', 'COST_TYPE', 'COUNTRY', 'FM_RESPONSIBLE', 'FM_COST_TYPE', 'COST', 'SUPPLIER_TYPE', 'SUPPLIER_TYPE_MOD', 'CONCAT_key', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD'] Valores únicos en ID_ORDER: ['F001108989' 'F001119565' 'F001119567' ... '701948955' '701948956' '701948957'] Valores únicos en COST_TYPE: ['Gasto' 'Inversion'] Valores únicos en COUNTRY: ['Espana' 'Mexico' 'Costa Rica' 'Panama' 'Peru' 'Republica Dominicana' 'Colombia'] Valores únicos en FM_RESPONSIBLE: ['Mantenimiento Multipunto' 'Mantenimiento' 'Eficiencia Energetica' 'Licencias' 'Obras Proyectos' 'Gestion Espacios'] Valores únicos en FM_COST_TYPE: ['Mtto. Contratos' 'Servicios Ctto.' 'Suministros' 'Eficiencia Energetica' 'Servicios Extra' 'Obras' 'Mtto. Correctivo' 'Licencias'] Valores únicos en COST: ['77' '36.95' '605.45' ... '89.412' '151.7735' '298.75'] Valores únicos en SUPPLIER_TYPE: ['EXTERNO' 'INTERNO' '0'] Valores únicos en SUPPLIER_TYPE_MOD: ['EXTERNO' 'INTERNO'] Valores únicos en CONCAT_key: ['3004832Mantenimiento MultipuntoEXTERNO' '1532MantenimientoEXTERNO' '3002542MantenimientoEXTERNO' ... '3007635MantenimientoEXTERNO' '3007643Obras ProyectosEXTERNO' '3006925MantenimientoEXTERNO'] Valores únicos en SUPPLIER_TYPE_MOD_2: ['EXTERNO' 'INTERNO'] Valores únicos en FM_RESPONSIBLE_MOD: ['Mantenimiento' 'Eficiencia Energetica' 'Licencias' 'Obras Proyectos' 'Gestion Espacios']
Generación del notebook en HTML¶
También vamos a generar un HTML para revisión interna y entrega al equipo de FM.
# Montar Drive (si no está montado)
from google.colab import drive
drive.mount('/content/drive')
Mounted at /content/drive
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v1-v3.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
Mounted at /content/drive [NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] Writing 727099 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v1-v3.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v1-v3.html | Existe: True
Introducción del variables exógenas del Catalogo_inmuebles del equipo de FM¶
Después de presentar la versión transformada (df_fd1_v3
) del dataset original al equipo de FM, con el fin de validarla, ellos nos han facilitado una base de datos adional (Catalogo_inmuebles_Actualizado_a_092025.xlsx) en la que se incluyen variables de contexto de los inmuebles históricos registrados en su CAFM. En esta base de datos adicional se aportan las siguientes variables: región (ID_REGION
), nivel de servicio (SERVICE_LEVEL
), estado de vigencia del inmueble (STATUS
) y si pertenece o no al perimetro de gestión de FM (FM_PERIMETER
). Estas variables se han considerado necesarias incluirlas en el dataset para el entrenamiento puesto aportan valor al contexto de los hechos registrados.
Se ha descartado añadir la superficie disponible del espacio a cada coste porque no se considera exista una misma relación para este nivel de detalle. También hemos acordado eliminar las contrataciones de CAPEX porque no son objeto de la predicción y para el caso que nos ocupan, producirían ruido para el modelo.
Con nuestro equipo de ingeniería hemos verificado además que si podamos usar las tildes y caracteres especiales tal y como nos vienen del origen de datos de FerMar S.L. De todas maneras, las versiones sin tilde las vamos a mantener por si el cliente las quisiera usar en alguna herramienta que no lea bien las tildes o caracteres especiales, ahora bien, para nuestro proyecto las vamos a usar como se nos entregan en su formato original de manera que puedan coincidir en las predicciones.
Como disponemos de un dilatado histórico de datos, que va desde enero 2021 hasta la actualidad (agosto 2025), vamos a realizar una primera iteración para realizar un entrenamiento acotando los datos de los años 2021, 2022 y 2023; con el fin de predecir el año 2024 y contrastar las predicciones con la realidad. Luego vamos a realizar una segunda iteración incorporando en el entrenamiento los registros de 2024, con el fin de validar y contrastar con datos reales hasta agosto 2025.
A continuación vamos a proceder para obtener una siguiente versión, la v4 del dataset origen ajustandolo según se ha comentado para luego segregarlo para obtener los conjuntos 2021-2023 de la 1a iteración (_ITE1) y 2021-2024 de la segunda (_ITE2), dejando a parte los dos dataset de contraste, uno para las previsiones del 2024 y el otro para las previsiones del 2025.
TRANSFORMACIÓN DEL DATASET V3 A V4¶
Cargamos en 2 datasets las fuentes iniciales del drive que nos van a servir en esta etapa:
El df_fd1_v3.xlsx que se encuentra en la ruta: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3.xlsx
El Catalogo_inmuebles_Actualizado_a_092025.xlsx que se encuentra en la ruta: /content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/CATALOGOS/Catalogo_inmuebles_Actualizado_a_092025.xlsx
# Montar Drive (si no está montado)
from google.colab import drive
drive.mount('/content/drive')
# cargamos df_fd1_v3.xlsx y Catalogo_inmuebles_Actualizado_a_092025.xlsx en dos dataframes.
# Definimos rutas (ajustar si fuese necesario)
ruta_fd1_v3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v3.xlsx"
ruta_catalogo = "/content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/CATALOGOS/Catalogo_inmuebles_Actualizado_a_092025.xlsx"
# Validamos que las rutas existen para evitar errores silenciosos
for ruta in [ruta_fd1_v3, ruta_catalogo]:
if not os.path.exists(ruta):
raise FileNotFoundError(f"No encontramos el archivo en la ruta: {ruta}. Verifiquemos el nombre o la carpeta.")
# Cargamos los Excel
# Si los ficheros tienen múltiples hojas y debemos elegir una, usamos sheet_name="NombreDeHoja".
# Por defecto, pandas leerá la primera hoja.
df_fd1_v3 = pd.read_excel(ruta_fd1_v3) # sheet_name=0 por defecto
df_catalogo_inmuebles = pd.read_excel(ruta_catalogo) # sheet_name=0 por defecto
# Mostramos un resumen rápido para verificar que todo está correcto
def resumen_df(nombre, df):
print(f"\n=== {nombre} ===")
print(f"Filas: {df.shape[0]:,} | Columnas: {df.shape[1]:,}")
print("Columnas:", list(df.columns)[:15], ("..." if df.shape[1] > 15 else ""))
display(df.head(3))
resumen_df("df_fd1_v3", df_fd1_v3)
resumen_df("df_catalogo_inmuebles", df_catalogo_inmuebles)
=== df_fd1_v3 === Filas: 323,090 | Columnas: 23 Columnas: ['ID_ORDER', 'COST_TYPE', 'COUNTRY', 'ID_BUILDING', 'YEAR_MONTH', 'FM_RESPONSIBLE', 'FM_COST_TYPE', 'COST', 'ID_CUSTOMER', 'ID_SUPPLIER', 'ID_BUSINESS_UNIT', 'SUPPLIER_TYPE', 'MONTH', 'YEAR', 'QUARTER'] ...
ID_ORDER | COST_TYPE | COUNTRY | ID_BUILDING | YEAR_MONTH | FM_RESPONSIBLE | FM_COST_TYPE | COST | ID_CUSTOMER | ID_SUPPLIER | ... | YEAR | QUARTER | cost_float | SUPPLIER_TYPE_MOD | ID_BUSINESS_UNIT_MOD | bu_corr_flag | CONCAT_key | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | F001108989 | Gasto | España | 1001131 | 202111 | Mantenimiento Multipunto | Mtto. Contratos | 77.00 | 3004581 | 3004832 | ... | 2021 | 4 | 77.00 | EXTERNO | 1000021 | False | 3004832Mantenimiento MultipuntoEXTERNO | 77.00 | EXTERNO | Mantenimiento |
1 | F001119565 | Gasto | España | 1000026 | 202107 | Mantenimiento | Mtto. Contratos | 36.95 | 13 | 1532 | ... | 2021 | 3 | 36.95 | EXTERNO | 1000021 | False | 1532MantenimientoEXTERNO | 36.95 | EXTERNO | Mantenimiento |
2 | F001119567 | Gasto | España | 1000515 | 202107 | Mantenimiento | Mtto. Contratos | 36.95 | 222 | 1532 | ... | 2021 | 3 | 36.95 | EXTERNO | 1000021 | False | 1532MantenimientoEXTERNO | 36.95 | EXTERNO | Mantenimiento |
3 rows × 23 columns
=== df_catalogo_inmuebles === Filas: 1,650 | Columnas: 10 Columnas: ['ID_BUILDING', 'ID_REGION', 'COUNTRY', 'STATUS', 'FM_PERIMETER', 'SERVICE_LEVEL', 'TIPO_USO', 'M2_AVAIL_PERIMETER', 'M2_AVAIL', 'M2_PROM_USO']
ID_BUILDING | ID_REGION | COUNTRY | STATUS | FM_PERIMETER | SERVICE_LEVEL | TIPO_USO | M2_AVAIL_PERIMETER | M2_AVAIL | M2_PROM_USO | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 118 | 17 | España | 1 | 1 | Compact | Bingo | 776.91 | 776.91 | 903.815556 |
1 | 648 | 2 | España | 0 | 0 | 0 | Sin Uso Actual | 0.00 | 0.00 | 0.000000 |
2 | 924 | 2 | España | 0 | 0 | 0 | Sin Uso Actual | 0.00 | 0.00 | 0.000000 |
Analisis Descriptivo de los Datos del Catalago_inmuebles¶
Ahora realizamos un analisis descriptivo de los datos del nuevo dataset para conocer sus valores unicos, performance estadístico, datos nulos,...
# Realizamos sun análisis descriptivo del catálogo de inmuebles
# para obtener estadísticas generales de df_catalogo_inmuebles
# Analisis estadístico estándar de pandas para las variables numéricas
print("=== DESCRIBE (numéricas) ===")
display(df_catalogo_inmuebles.describe().T)
# Añadimos también categóricas (object / string)
print("\n=== DESCRIBE (incluyendo categóricas) ===")
display(df_catalogo_inmuebles.describe(include="all").T)
# Analizamos el tipos de datos y vemos si hay nulos
print("\n=== INFO ===")
print(df_catalogo_inmuebles.info())
# Contamos los valores nulos por columna
print("\n=== NULOS POR COLUMNA ===")
print(df_catalogo_inmuebles.isna().sum())
# Identificamos variables categóricas
cat_cols = df_catalogo_inmuebles.select_dtypes(include=["object"]).columns
# Visualizamos sus valores únicos
print("=== VARIABLES CATEGÓRICAS ===")
for col in cat_cols:
print(f"\nColumna: {col}")
print(df_catalogo_inmuebles[col].unique())
# Identificamos variables numéricas
num_cols = df_catalogo_inmuebles.select_dtypes(include=["int64", "float64"]).columns
# Visualizamos los valores únicos de las variables numéricas con menos de 5 clases
print("\n=== VARIABLES NUMÉRICAS CON < 5 CLASES ===")
for col in num_cols:
valores_unicos = df_catalogo_inmuebles[col].unique()
n_unicos = len(valores_unicos)
if n_unicos < 5:
print(f"\nColumna: {col} | Nº clases: {n_unicos}")
print(valores_unicos)
=== DESCRIBE (numéricas) ===
count | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|
ID_BUILDING | 1650.0 | 616661.073939 | 486695.604139 | 2.0 | 1018.25 | 1000559.0 | 1.001184e+06 | 1001613.00 |
ID_REGION | 1650.0 | 164882.419394 | 371160.385033 | 2.0 | 5.00 | 32.0 | 9.400000e+01 | 1000164.00 |
STATUS | 1650.0 | 0.500606 | 0.500151 | 0.0 | 0.00 | 1.0 | 1.000000e+00 | 1.00 |
FM_PERIMETER | 1650.0 | 0.373939 | 0.483995 | 0.0 | 0.00 | 0.0 | 1.000000e+00 | 1.00 |
M2_AVAIL_PERIMETER | 1650.0 | 234.415225 | 780.341649 | 0.0 | 0.00 | 0.0 | 1.919435e+02 | 23753.08 |
M2_AVAIL | 1650.0 | 222.693352 | 771.409055 | 0.0 | 0.00 | 0.0 | 1.775225e+02 | 23753.08 |
M2_PROM_USO | 1650.0 | 233.273661 | 708.844211 | 0.0 | 0.00 | 0.0 | 1.921979e+02 | 23753.08 |
=== DESCRIBE (incluyendo categóricas) ===
count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|---|---|---|
ID_BUILDING | 1650.0 | NaN | NaN | NaN | 616661.073939 | 486695.604139 | 2.0 | 1018.25 | 1000559.0 | 1001183.75 | 1001613.0 |
ID_REGION | 1650.0 | NaN | NaN | NaN | 164882.419394 | 371160.385033 | 2.0 | 5.0 | 32.0 | 94.0 | 1000164.0 |
COUNTRY | 1650 | 13 | España | 1166 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
STATUS | 1650.0 | NaN | NaN | NaN | 0.500606 | 0.500151 | 0.0 | 0.0 | 1.0 | 1.0 | 1.0 |
FM_PERIMETER | 1650.0 | NaN | NaN | NaN | 0.373939 | 0.483995 | 0.0 | 0.0 | 0.0 | 1.0 | 1.0 |
SERVICE_LEVEL | 1650.0 | 11.0 | 0.0 | 824.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
TIPO_USO | 1650 | 15 | Sin Uso Actual | 1033 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
M2_AVAIL_PERIMETER | 1650.0 | NaN | NaN | NaN | 234.415225 | 780.341649 | 0.0 | 0.0 | 0.0 | 191.943456 | 23753.08 |
M2_AVAIL | 1650.0 | NaN | NaN | NaN | 222.693352 | 771.409055 | 0.0 | 0.0 | 0.0 | 177.5225 | 23753.08 |
M2_PROM_USO | 1650.0 | NaN | NaN | NaN | 233.273661 | 708.844211 | 0.0 | 0.0 | 0.0 | 192.197941 | 23753.08 |
=== INFO === <class 'pandas.core.frame.DataFrame'> RangeIndex: 1650 entries, 0 to 1649 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ID_BUILDING 1650 non-null int64 1 ID_REGION 1650 non-null int64 2 COUNTRY 1650 non-null object 3 STATUS 1650 non-null int64 4 FM_PERIMETER 1650 non-null int64 5 SERVICE_LEVEL 1650 non-null object 6 TIPO_USO 1650 non-null object 7 M2_AVAIL_PERIMETER 1650 non-null float64 8 M2_AVAIL 1650 non-null float64 9 M2_PROM_USO 1650 non-null float64 dtypes: float64(3), int64(4), object(3) memory usage: 129.0+ KB None === NULOS POR COLUMNA === ID_BUILDING 0 ID_REGION 0 COUNTRY 0 STATUS 0 FM_PERIMETER 0 SERVICE_LEVEL 0 TIPO_USO 0 M2_AVAIL_PERIMETER 0 M2_AVAIL 0 M2_PROM_USO 0 dtype: int64 === VARIABLES CATEGÓRICAS === Columna: COUNTRY ['España' 'Perú' 'Panamá' 'México' 'Costa Rica' 'Colombia' 'Italia' 'República Dominicana' 'desconocido' 'Malta' 'Puerto Rico' 'Marruecos' 'Venezuela'] Columna: SERVICE_LEVEL ['Compact' 0 'Solo Suministros' 'Sin Servicio' 'Básico' 'Completo' 'Esencial' 'Económico' 'Fuera Perímetro' 'eCorner' 'Solo Licencias'] Columna: TIPO_USO ['Bingo' 'Sin Uso Actual' 'Salón de Juego' 'Bar/Restaurante' 'Delegación' 'Sin actividad' 'Parking' 'Oficinas' 'Casino Electrónico' 'Subarrendado' 'Almacén/Bodega' 'Casino Tradicional' 'Industrial' 'Local Apuestas' 'Hotel'] === VARIABLES NUMÉRICAS CON < 5 CLASES === Columna: STATUS | Nº clases: 2 [1 0] Columna: FM_PERIMETER | Nº clases: 2 [1 0]
No hay nulos en ninguna columna, por consiguiente podemos afirmar que el dataset viene limpio.
Variables categóricas:
COUNTRY
: 13 valores (incluye "desconocido").SERVICE_LEVEL
: mezcla de categorías y un 0 que parece un error tipológico (habría que revisarlo).TIPO_USO
: 15 categorías con diferentes usos de inmueble.
Variables numéricas con menos de 5 clases:
STATUS
: binaria (1 = vigente, 0 = no vigente).FM_PERIMETER
: binaria (1 = dentro del perímetro FM, 0 = fuera).
Con esto confirmamos que:
STATUS
yFM_PERIMETER
servirán como filtros obligatorios en la creación de la v4.ID_REGION
yTIPO_USO
se pueden usar como variables de contexto (features).SERVICE_LEVEL
también aporta contexto, pero primero deberíamos limpiar ese valor 0 para evitar ruido.
Conclusión del equipo de FM¶
Después de comentar con el equipo de FM los valores 0 de la variable SERVICE_LEVEL
, vamos a verificar si es cierto que se pueden eliminar todos los 0 porque pertenecen a espacios o bien que su STATUS es 0 o bien que su FM_PERIMETER
es 0.
# Filtramos las filas con SERVICE_LEVEL = 0
df_service_level_cero = df_catalogo_inmuebles[df_catalogo_inmuebles["SERVICE_LEVEL"] == 0]
# Mostramos cantidad de registros encontrados
print(f"Registros con SERVICE_LEVEL = 0: {len(df_service_level_cero)}")
# Obtenemos valores únicos de FM_PERIMETER y STATUS para los casos con SERVICE_LEVEL igual a 0.
print("\nValores únicos de FM_PERIMETER en esos casos:")
print(df_service_level_cero["FM_PERIMETER"].unique())
print("\nValores únicos de STATUS en esos casos:")
print(df_service_level_cero["STATUS"].unique())
# Mostramos las primeras filas para inspección manual
display(df_service_level_cero.head())
Registros con SERVICE_LEVEL = 0: 824 Valores únicos de FM_PERIMETER en esos casos: [0] Valores únicos de STATUS en esos casos: [0]
ID_BUILDING | ID_REGION | COUNTRY | STATUS | FM_PERIMETER | SERVICE_LEVEL | TIPO_USO | M2_AVAIL_PERIMETER | M2_AVAIL | M2_PROM_USO | |
---|---|---|---|---|---|---|---|---|---|---|
1 | 648 | 2 | España | 0 | 0 | 0 | Sin Uso Actual | 0.0 | 0.0 | 0.0 |
2 | 924 | 2 | España | 0 | 0 | 0 | Sin Uso Actual | 0.0 | 0.0 | 0.0 |
3 | 1001102 | 1000025 | Perú | 0 | 0 | 0 | Sin Uso Actual | 0.0 | 0.0 | 0.0 |
4 | 1000841 | 12 | España | 0 | 0 | 0 | Sin Uso Actual | 0.0 | 0.0 | 0.0 |
5 | 927 | 2 | España | 0 | 0 | 0 | Sin Uso Actual | 0.0 | 0.0 | 0.0 |
Se confirma que si eliminamos los registros con valor 0 en STATUS
y en FM_PERIMETER
, vamos a dejar sin 0 la variable SERVICE_LEVEL
. Vamos a verificar este punto al final de la eliminación.
# Creamos un nuevo dataframe filtrado
df_catalogo_inmuebles_filtrado = df_catalogo_inmuebles[
(df_catalogo_inmuebles["STATUS"] == 1) &
(df_catalogo_inmuebles["FM_PERIMETER"] == 1)
].copy()
print("=== RESUMEN FILTRADO ===")
print(f"Filas originales: {len(df_catalogo_inmuebles):,}")
print(f"Filas después del filtrado: {len(df_catalogo_inmuebles_filtrado):,}")
# Verificamos que ya no exista el valor 0 en SERVICE_LEVEL
valores_unicos_service = df_catalogo_inmuebles_filtrado["SERVICE_LEVEL"].unique()
print("\nValores únicos de SERVICE_LEVEL después del filtrado:")
print(valores_unicos_service)
# Verificamos también que STATUS y FM_PERIMETER sean constantes en 1
print("\nValores únicos de STATUS en el catálogo filtrado:", df_catalogo_inmuebles_filtrado["STATUS"].unique())
print("Valores únicos de FM_PERIMETER en el catálogo filtrado:", df_catalogo_inmuebles_filtrado["FM_PERIMETER"].unique())
# Realizamos una vista rápida de los primeros registros filtrados
display(df_catalogo_inmuebles_filtrado.head())
=== RESUMEN FILTRADO === Filas originales: 1,650 Filas después del filtrado: 617 Valores únicos de SERVICE_LEVEL después del filtrado: ['Compact' 'Solo Suministros' 'Básico' 'Completo' 'Esencial' 'Económico' 'Solo Licencias'] Valores únicos de STATUS en el catálogo filtrado: [1] Valores únicos de FM_PERIMETER en el catálogo filtrado: [1]
ID_BUILDING | ID_REGION | COUNTRY | STATUS | FM_PERIMETER | SERVICE_LEVEL | TIPO_USO | M2_AVAIL_PERIMETER | M2_AVAIL | M2_PROM_USO | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 118 | 17 | España | 1 | 1 | Compact | Bingo | 776.91 | 776.91 | 903.815556 |
32 | 1001066 | 38 | España | 1 | 1 | Solo Suministros | Salón de Juego | 130.00 | 130.00 | 192.197941 |
43 | 1000922 | 48 | España | 1 | 1 | Básico | Salón de Juego | 210.93 | 210.93 | 192.197941 |
45 | 1001141 | 8 | España | 1 | 1 | Compact | Salón de Juego | 94.63 | 94.63 | 192.197941 |
47 | 1001152 | 6 | España | 1 | 1 | Compact | Salón de Juego | 94.48 | 94.48 | 192.197941 |
Analisis y eliminación de registros del dataset df_fd1_v3 con inmuebles de baja o fuera del perímetro¶
Primero vamos a realizar una verificación de todos los ID_BUILDING
del dataset df_fd1_v3
que no encuentran ID_BUILDING
en df_catalogo_inmuebles_filtrado
. Este grupo de registros con ID_BUILDING
no encontrados los vamos a guardar porque forman parte del histórico de coste, aunque lo es de inmuebles que hoy ya no están en activo o no son de gestión de FM y por lo tanto si se pueden eliminar porque provocan ruido en el dataset y ya no aportan valor. Este grupo de ID_BUILDING
también los guardaremos en un diccionario y eliminaremos todos sus registros del df_fd1_v3
y los dejaremos guardados en un dataset df_fd1_v3_inmuebles_no_vigentes
.
# Alineamos tipos de ID_BUILDING para evitar desajustes en la comparación
# (lo hacemos de forma segura por si alguna fuente viene como string)
df_fd1_v3["ID_BUILDING"] = pd.to_numeric(df_fd1_v3["ID_BUILDING"], errors="coerce")
df_catalogo_inmuebles_filtrado["ID_BUILDING"] = pd.to_numeric(df_catalogo_inmuebles_filtrado["ID_BUILDING"], errors="coerce")
# Obtenemos conjuntos de IDs
ids_v3 = set(df_fd1_v3["ID_BUILDING"].dropna().unique())
ids_catalogo = set(df_catalogo_inmuebles_filtrado["ID_BUILDING"].dropna().unique())
# Calculamos los ID_BUILDING de v3 que no existen en el catálogo filtrado
ids_fuera_catalogo = ids_v3 - ids_catalogo
print(f"ID_BUILDING en v3: {len(ids_v3):,} | ID_BUILDING en catálogo filtrado: {len(ids_catalogo):,}")
print(f"ID_BUILDING de v3 fuera del catálogo filtrado: {len(ids_fuera_catalogo):,}")
# Creamos el diccionario con nº de registros por ID_BUILDING excluido
dicc_ids_fuera = (
df_fd1_v3[df_fd1_v3["ID_BUILDING"].isin(ids_fuera_catalogo)]
.groupby("ID_BUILDING")
.size()
.to_dict()
)
print("\n=== Diccionario (primeros 10) de inmuebles excluidos ===")
for i, (k, v) in enumerate(dicc_ids_fuera.items()):
if i < 10:
print(f"ID_BUILDING: {k} -> Registros: {v}")
else:
break
print(f"Total inmuebles excluidos: {len(dicc_ids_fuera):,}")
# Separamos datasets:
# - df_fd1_v3_inmuebles_no_vigentes: registros a eliminar del histórico
# - df_fd1_v3_limpio: registros válidos que conservaremos
df_fd1_v3_inmuebles_no_vigentes = df_fd1_v3[df_fd1_v3["ID_BUILDING"].isin(ids_fuera_catalogo)].copy()
df_fd1_v3_limpio = df_fd1_v3[~df_fd1_v3["ID_BUILDING"].isin(ids_fuera_catalogo)].copy()
print("\n=== RESUMEN SEPARACIÓN ===")
print(f"Filas originales en v3: {len(df_fd1_v3):,}")
print(f"Filas excluidas (no vigentes / fuera de perímetro): {len(df_fd1_v3_inmuebles_no_vigentes):,}")
print(f"Filas restantes (v3 limpio): {len(df_fd1_v3_limpio):,}")
# Reemplazamos df_fd1_v3 por su versión limpia para continuar el pipeline
df_fd1_v3 = df_fd1_v3_limpio
del df_fd1_v3_limpio # liberamos memoria
# Verificamos que todos los ID_BUILDING de v3 existan en el catálogo filtrado
resto_ids_fuera = set(df_fd1_v3["ID_BUILDING"].dropna().unique()) - ids_catalogo
print(f"\nIDs fuera de catálogo tras limpieza (debería ser 0): {len(resto_ids_fuera)}")
ID_BUILDING en v3: 693 | ID_BUILDING en catálogo filtrado: 617 ID_BUILDING de v3 fuera del catálogo filtrado: 127 === Diccionario (primeros 10) de inmuebles excluidos === ID_BUILDING: 169 -> Registros: 86 ID_BUILDING: 180 -> Registros: 31 ID_BUILDING: 182 -> Registros: 110 ID_BUILDING: 196 -> Registros: 2 ID_BUILDING: 198 -> Registros: 61 ID_BUILDING: 342 -> Registros: 1 ID_BUILDING: 344 -> Registros: 14 ID_BUILDING: 357 -> Registros: 421 ID_BUILDING: 566 -> Registros: 1 ID_BUILDING: 618 -> Registros: 149 Total inmuebles excluidos: 127 === RESUMEN SEPARACIÓN === Filas originales en v3: 323,090 Filas excluidas (no vigentes / fuera de perímetro): 6,219 Filas restantes (v3 limpio): 316,871 IDs fuera de catálogo tras limpieza (debería ser 0): 0
# Visualizamos el dataset del historico de contratación asociado a espacios no vigentes o fuera de perimetro FM.
df_fd1_v3_inmuebles_no_vigentes
ID_ORDER | COST_TYPE | COUNTRY | ID_BUILDING | YEAR_MONTH | FM_RESPONSIBLE | FM_COST_TYPE | COST | ID_CUSTOMER | ID_SUPPLIER | ... | YEAR | QUARTER | cost_float | SUPPLIER_TYPE_MOD | ID_BUSINESS_UNIT_MOD | bu_corr_flag | CONCAT_key | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
7 | F001126168 | Gasto | España | 1136 | 202107 | Mantenimiento | Mtto. Contratos | 43.670000 | 161 | 1532 | ... | 2021 | 3 | 43.670000 | EXTERNO | 51 | False | 1532MantenimientoEXTERNO | 43.670000 | EXTERNO | Mantenimiento |
8 | F001126191 | Gasto | México | 1000454 | 202101 | Eficiencia Energética | Suministros | 1485.231000 | 3001510 | 3006565 | ... | 2021 | 1 | 1485.231000 | EXTERNO | 43 | False | 3006565Eficiencia EnergéticaEXTERNO | 1485.231000 | EXTERNO | Eficiencia Energética |
128 | F001127165 | Gasto | Panamá | 1088 | 202101 | Eficiencia Energética | Suministros | 12977.763636 | 2047 | 3004370 | ... | 2021 | 1 | 12977.763636 | EXTERNO | 43 | False | 3004370Eficiencia EnergéticaEXTERNO | 12977.763636 | EXTERNO | Eficiencia Energética |
134 | F001127171 | Gasto | Panamá | 1000534 | 202101 | Eficiencia Energética | Suministros | 3365.409091 | 2047 | 3004370 | ... | 2021 | 1 | 3365.409091 | EXTERNO | 43 | False | 3004370Eficiencia EnergéticaEXTERNO | 3365.409091 | EXTERNO | Eficiencia Energética |
140 | F001127177 | Gasto | Panamá | 1000869 | 202101 | Eficiencia Energética | Suministros | 7774.654545 | 2047 | 3004370 | ... | 2021 | 1 | 7774.654545 | EXTERNO | 43 | False | 3004370Eficiencia EnergéticaEXTERNO | 7774.654545 | EXTERNO | Eficiencia Energética |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
322878 | 701947735 | Gasto | España | 1001599 | 202509 | Obras Proyectos | Obras | 220.000000 | 2676 | 3007016 | ... | 2025 | 3 | 220.000000 | EXTERNO | 2606 | False | 3007016Obras ProyectosEXTERNO | 220.000000 | EXTERNO | Obras Proyectos |
322879 | 701947755 | Inversión | España | 1001601 | 202509 | Obras Proyectos | Obras | 498.000000 | 3003189 | 3003277 | ... | 2025 | 3 | 498.000000 | EXTERNO | 2606 | False | 3003277Obras ProyectosEXTERNO | 498.000000 | EXTERNO | Obras Proyectos |
322914 | 701947928 | Gasto | España | 1001600 | 202509 | Obras Proyectos | Obras | 80.000000 | 1434 | 3007016 | ... | 2025 | 3 | 80.000000 | EXTERNO | 2606 | False | 3007016Obras ProyectosEXTERNO | 80.000000 | EXTERNO | Obras Proyectos |
323086 | 701948953 | Gasto | España | 1001601 | 202509 | Obras Proyectos | Obras | 326.000000 | 3003189 | 3005230 | ... | 2025 | 3 | 326.000000 | EXTERNO | 2606 | False | 3005230Obras ProyectosEXTERNO | 326.000000 | EXTERNO | Obras Proyectos |
323087 | 701948955 | Gasto | España | 1001598 | 202509 | Obras Proyectos | Obras | 348.750000 | 3005142 | 3006714 | ... | 2025 | 3 | 348.750000 | EXTERNO | 2606 | False | 3006714Obras ProyectosEXTERNO | 348.750000 | EXTERNO | Obras Proyectos |
6219 rows × 23 columns
Hasta aquí ya tenemos el df_fd1_v3
limpio para ser ampliado con nuevo contexto y generar la versión v4.
Ahora ya podemos realizar la unión de los dos dataset a través de la llave única ID_BUILDING
. Procedemos a realizar las agregaciones de variables ID_REGION
, FM_PERIMETER
, STATUS
y TIPO_USO
del dataset df_catalogo_inmuebles_filtrado
al dataset df_fd1_v3
y la unión la vamos a llamar df_fd1_v4
.
# Guardamos el diccionario de excluidos como dataframe para trazabilidad
df_inmuebles_excluidos = (
pd.DataFrame(list(dicc_ids_fuera.items()), columns=["ID_BUILDING", "num_registros_excluidos"])
.sort_values("num_registros_excluidos", ascending=False)
.reset_index(drop=True)
)
print("=== TRAZABILIDAD EXCLUIDOS (top 10) ===")
display(df_inmuebles_excluidos.head(10))
# Seleccionamos columnas del catálogo a incorporar como contexto
cols_ctx = ["ID_BUILDING", "ID_REGION", "SERVICE_LEVEL", "TIPO_USO", "COUNTRY"]
catalogo_ctx = df_catalogo_inmuebles_filtrado[cols_ctx].copy()
# Verificamos unicidad de ID_BUILDING en el catálogo filtrado
dups = catalogo_ctx["ID_BUILDING"].duplicated(keep="first").sum()
if dups > 0:
print(f"Aviso: encontramos {dups} ID_BUILDING duplicados en el catálogo filtrado. "
f"Nos quedamos con la primera aparición.")
catalogo_ctx = catalogo_ctx.drop_duplicates(subset=["ID_BUILDING"], keep="first")
# Alineamos tipos (por seguridad)
df_fd1_v3["ID_BUILDING"] = pd.to_numeric(df_fd1_v3["ID_BUILDING"], errors="coerce")
catalogo_ctx["ID_BUILDING"] = pd.to_numeric(catalogo_ctx["ID_BUILDING"], errors="coerce")
# Calculamos intersección de ID_BUILDING como control previo
ids_inter = pd.Index(df_fd1_v3["ID_BUILDING"].unique()).intersection(catalogo_ctx["ID_BUILDING"].unique())
print(f"Edificios únicos en v3 limpio: {df_fd1_v3['ID_BUILDING'].nunique():,}")
print(f"Edificios únicos en catálogo filtrado: {catalogo_ctx['ID_BUILDING'].nunique():,}")
print(f"Edificios en intersección: {len(ids_inter):,}")
# Realizamos la unión (left join) v3 limpio + catálogo filtrado para crear v4
df_fd1_v4 = df_fd1_v3.merge(
catalogo_ctx,
on="ID_BUILDING",
how="left",
validate="m:1" # muchas órdenes por edificio, un registro por edificio en el catálogo filtrado
)
# === Normalizamos COUNTRY tras el merge y calculamos cobertura sin errores ===
# Si existen ambas, nos quedamos con COUNTRY (v3) y renombramos la del catálogo
cols = set(df_fd1_v4.columns)
if "COUNTRY_x" in cols and "COUNTRY_y" in cols:
df_fd1_v4 = df_fd1_v4.rename(columns={
"COUNTRY_x": "COUNTRY", # la de v3
"COUNTRY_y": "COUNTRY_CATALOGO" # la del catálogo
})
elif "COUNTRY_y" in cols and "COUNTRY" not in cols:
# Caso raro: solo llegó la del catálogo
df_fd1_v4 = df_fd1_v4.rename(columns={"COUNTRY_y": "COUNTRY_CATALOGO"})
# Si solo existe COUNTRY, no hacemos nada.
# Definimos las columnas nuevas de contexto de forma segura
candidatas_nuevas = ["ID_REGION", "SERVICE_LEVEL", "TIPO_USO", "COUNTRY_CATALOGO", "COUNTRY"]
cols_nuevas_presentes = [c for c in candidatas_nuevas if c in df_fd1_v4.columns]
# Cobertura: filas con todas las nuevas columnas a NA
if cols_nuevas_presentes:
mask_na = df_fd1_v4[cols_nuevas_presentes].isna().all(axis=1)
num_na = mask_na.sum()
porc_na = 100.0 * num_na / len(df_fd1_v4)
else:
num_na, porc_na = 0, 0.0 # si por algún motivo no está ninguna, evitamos fallo
print("\n=== RESUMEN UNIÓN v3 limpio + catálogo inmuebles filtrado ===")
print(f"Filas v3 limpio: {len(df_fd1_v3):,} -> Filas v4: {len(df_fd1_v4):,}")
print(f"Columnas añadidas detectadas: {cols_nuevas_presentes}")
print(f"Filas sin match (todas las nuevas columnas NA): {num_na:,} ({porc_na:.2f}%)")
# Visualizamos si tenemos valores NAs por columna nueva para diagnóstico
if cols_nuevas_presentes:
print("\nNAs por columna nueva:")
print(df_fd1_v4[cols_nuevas_presentes].isna().sum().sort_values(ascending=False))
=== TRAZABILIDAD EXCLUIDOS (top 10) ===
ID_BUILDING | num_registros_excluidos | |
---|---|---|
0 | 1000759 | 467 |
1 | 357 | 421 |
2 | 1000454 | 380 |
3 | 1000869 | 368 |
4 | 1237 | 329 |
5 | 1244 | 191 |
6 | 1000774 | 170 |
7 | 961 | 155 |
8 | 618 | 149 |
9 | 1000770 | 129 |
Edificios únicos en v3 limpio: 566 Edificios únicos en catálogo filtrado: 617 Edificios en intersección: 566 === RESUMEN UNIÓN v3 limpio + catálogo inmuebles filtrado === Filas v3 limpio: 316,871 -> Filas v4: 316,871 Columnas añadidas detectadas: ['ID_REGION', 'SERVICE_LEVEL', 'TIPO_USO', 'COUNTRY_CATALOGO', 'COUNTRY'] Filas sin match (todas las nuevas columnas NA): 0 (0.00%) NAs por columna nueva: ID_REGION 0 SERVICE_LEVEL 0 TIPO_USO 0 COUNTRY_CATALOGO 0 COUNTRY 0 dtype: int64
¡La unión parece haber salido perfecta!
Eliminación de los registros de CAPEX (Inversión)¶
Segundo vamos a realizar la eliminación de los registros de la variable COST_TYPE
cuando su valor es Inversión. La parte eliminada la vamos a guardar en un dataframe exclusivo de capex llamado df_fd1_v4_solo_capex
.
# Identificamos los valores únicos de la variable COST_TYPE
valores_cost_type = df_fd1_v3["COST_TYPE"].unique()
print("=== Valores únicos de COST_TYPE ===")
print(valores_cost_type)
# (Opcional) Conteo de frecuencia de cada valor
print("\n=== Frecuencia de COST_TYPE ===")
print(df_fd1_v3["COST_TYPE"].value_counts())
=== Valores únicos de COST_TYPE === ['Gasto' 'Inversión'] === Frecuencia de COST_TYPE === COST_TYPE Gasto 306775 Inversión 10096 Name: count, dtype: int64
# Separaramos registros de inversión y dejamos solo gastos en df_fd1_v4
# Creamos dataset con registros de inversión (CAPEX)
df_fd1_v4_capex = df_fd1_v4[df_fd1_v4["COST_TYPE"] == "Inversión"].copy()
# Creamos dataset con registros de gasto (OPEX)
df_fd1_v4_opex = df_fd1_v4[df_fd1_v4["COST_TYPE"] == "Gasto"].copy()
# Visualizamos un resumen de la operación
print("=== RESUMEN CAPEX/OPEX EN v4 ===")
print(f"Filas totales en v4: {len(df_fd1_v4):,}")
print(f"Filas detectadas como CAPEX (Inversión): {len(df_fd1_v4_capex):,}")
print(f"Filas restantes (OPEX - Gasto): {len(df_fd1_v4_opex):,}")
# Visualizamos una vista rápida de control de cada dataset
print("\nEjemplo registros CAPEX:")
display(df_fd1_v4_capex.head(3)[["ID_ORDER", "ID_BUILDING", "COST_TYPE", "FM_COST_TYPE", "COST"]])
print("\nEjemplo registros OPEX:")
display(df_fd1_v4_opex.head(3)[["ID_ORDER", "ID_BUILDING", "COST_TYPE", "FM_COST_TYPE", "COST"]])
=== RESUMEN CAPEX/OPEX EN v4 === Filas totales en v4: 316,871 Filas detectadas como CAPEX (Inversión): 10,096 Filas restantes (OPEX - Gasto): 306,775 Ejemplo registros CAPEX:
ID_ORDER | ID_BUILDING | COST_TYPE | FM_COST_TYPE | COST | |
---|---|---|---|---|---|
86708 | 1389897 | 1281 | Inversión | Obras | 212.0736 |
86709 | 1391872 | 1213 | Inversión | Obras | 133956.9928 |
86710 | 1392770 | 1001116 | Inversión | Obras | 11657.5300 |
Ejemplo registros OPEX:
ID_ORDER | ID_BUILDING | COST_TYPE | FM_COST_TYPE | COST | |
---|---|---|---|---|---|
0 | F001108989 | 1001131 | Gasto | Mtto. Contratos | 77.00 |
1 | F001119565 | 1000026 | Gasto | Mtto. Contratos | 36.95 |
2 | F001119567 | 1000515 | Gasto | Mtto. Contratos | 36.95 |
Exportación de las versiones v4 al Drive¶
# Exportamos al Drive df_fd1_v4 (total), df_fd1_v4_capex (Inversión) y df_fd1_v4_opex (Gasto)
# en formato Excel (.xlsx) y CSV con separador ';'
# Definimos ruta de salida
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# Guardamos la versión completa (antes de separar CAPEX/OPEX)
df_fd1_v4.to_excel(f"{ruta_out}/df_fd1_v4_total.xlsx", index=False)
df_fd1_v4.to_csv(f"{ruta_out}/df_fd1_v4_total.csv", sep=";", index=False)
# Guardamos la versión CAPEX (Inversión)
df_fd1_v4_capex.to_excel(f"{ruta_out}/df_fd1_v4_capex.xlsx", index=False)
df_fd1_v4_capex.to_csv(f"{ruta_out}/df_fd1_v4_capex.csv", sep=";", index=False)
# Guardamos la versión OPEX (Gasto) → la que será la oficial para modelado
df_fd1_v4_opex.to_excel(f"{ruta_out}/df_fd1_v4_opex.xlsx", index=False)
df_fd1_v4_opex.to_csv(f"{ruta_out}/df_fd1_v4_opex.csv", sep=";", index=False)
print("=== ARCHIVOS GUARDADOS ===")
print("1) df_fd1_v4_total.xlsx / .csv")
print("2) df_fd1_v4_capex.xlsx / .csv")
print("3) df_fd1_v4_opex.xlsx / .csv")
print(f"Ubicación: {ruta_out}")
=== ARCHIVOS GUARDADOS === 1) df_fd1_v4_total.xlsx / .csv 2) df_fd1_v4_capex.xlsx / .csv 3) df_fd1_v4_opex.xlsx / .csv Ubicación: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO
Ya hemos obtenido el Dataset mejorado en su versión v4 exclusiva para seguir con la predicción del OPEX.
TRANSFORMACIÓN DEL DATASET V4 A V5¶
En esta ocasión sobre el dataset v4 opex obtenido en la etapa anterior.
Cargamos el dataset df_fd1_v4_opex
¶
# Cargamos el dataset v4_opex desde los archivos guardados
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# cargamos desde Excel
df_fd1_v4_opex = pd.read_excel(f"{ruta_out}/df_fd1_v4_opex.xlsx")
# cargamos desde CSV (usando ; como separador)
# df_fd1_v4_opex = pd.read_csv(f"{ruta_out}/df_fd1_v4_opex.csv", sep=";")
print("=== Dataset recargado ===")
print(f"Filas: {len(df_fd1_v4_opex):,} | Columnas: {len(df_fd1_v4_opex.columns):,}")
print("Columnas:", df_fd1_v4_opex.columns.tolist()[:15], "...")
=== Dataset recargado === Filas: 306,775 | Columnas: 27 Columnas: ['ID_ORDER', 'COST_TYPE', 'COUNTRY', 'ID_BUILDING', 'YEAR_MONTH', 'FM_RESPONSIBLE', 'FM_COST_TYPE', 'COST', 'ID_CUSTOMER', 'ID_SUPPLIER', 'ID_BUSINESS_UNIT', 'SUPPLIER_TYPE', 'MONTH', 'YEAR', 'QUARTER'] ...
Vamos a clasificar de nuevo todas las variables del dataset df_fd1_v4_opex
.
# Categorizamos todas las columnas según su naturaleza y el valor aportado
# Listamos todas las columnas
todas_cols = df_fd1_v4_opex.columns.tolist()
print("=== LISTA DE TODAS LAS VARIABLES ===")
print(todas_cols)
# Definimos la dependiente (target = cost_float_mod)
var_dependiente = "cost_float_mod" if "cost_float_mod" in df_fd1_v4_opex.columns else None
# Definimos las variables identificadoras (claves y códigos que no deben usarse como predictores directos)
vars_id = [
"ID_ORDER", "ID_BUILDING", "ID_CUSTOMER", "ID_SUPPLIER",
"ID_BUSINESS_UNIT", "ID_BUSINESS_UNIT_MOD", "CONCAT_key"
]
vars_id = [v for v in vars_id if v in df_fd1_v4_opex.columns]
# Definimos las variables temporales
vars_tiempo = [c for c in ["YEAR_MONTH", "YEAR", "MONTH", "QUARTER"] if c in df_fd1_v4_opex.columns]
# Definimos las variables de coste
vars_coste = [c for c in ["COST", "cost_float", "cost_float_mod"] if c in df_fd1_v4_opex.columns]
# Definimos las variables categóricas basadas en dtype (object)
vars_categoricas = df_fd1_v4_opex.select_dtypes(include=["object"]).columns.tolist()
# Forzamos ID_REGION como categórica
if "ID_REGION" in df_fd1_v4_opex.columns and "ID_REGION" not in vars_categoricas:
vars_categoricas.append("ID_REGION")
# Convertimos el dtype a 'category' para evitar que entre en análisis numérico
df_fd1_v4_opex["ID_REGION"] = df_fd1_v4_opex["ID_REGION"].astype("category")
# Variables numéricas restantes (posibles features cuantitativos)
vars_numericas = [
c for c in df_fd1_v4_opex.select_dtypes(include=["int64", "float64"]).columns.tolist()
if c not in vars_coste + vars_tiempo + vars_id
]
# Visualizamos un resumen de clasificación
print("\n=== CLASIFICACIÓN DE VARIABLES ===")
print(f"Dependiente (target): {var_dependiente}")
print(f"Identificadores: {vars_id}")
print(f"Temporales: {vars_tiempo}")
print(f"Variables de coste: {vars_coste}")
print(f"Categóricas: {vars_categoricas}")
print(f"Numéricas (otras): {vars_numericas}")
=== LISTA DE TODAS LAS VARIABLES === ['ID_ORDER', 'COST_TYPE', 'COUNTRY', 'ID_BUILDING', 'YEAR_MONTH', 'FM_RESPONSIBLE', 'FM_COST_TYPE', 'COST', 'ID_CUSTOMER', 'ID_SUPPLIER', 'ID_BUSINESS_UNIT', 'SUPPLIER_TYPE', 'MONTH', 'YEAR', 'QUARTER', 'cost_float', 'SUPPLIER_TYPE_MOD', 'ID_BUSINESS_UNIT_MOD', 'bu_corr_flag', 'CONCAT_key', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'ID_REGION', 'SERVICE_LEVEL', 'TIPO_USO', 'COUNTRY_CATALOGO'] === CLASIFICACIÓN DE VARIABLES === Dependiente (target): cost_float_mod Identificadores: ['ID_ORDER', 'ID_BUILDING', 'ID_CUSTOMER', 'ID_SUPPLIER', 'ID_BUSINESS_UNIT', 'ID_BUSINESS_UNIT_MOD', 'CONCAT_key'] Temporales: ['YEAR_MONTH', 'YEAR', 'MONTH', 'QUARTER'] Variables de coste: ['COST', 'cost_float', 'cost_float_mod'] Categóricas: ['ID_ORDER', 'COST_TYPE', 'COUNTRY', 'FM_RESPONSIBLE', 'FM_COST_TYPE', 'SUPPLIER_TYPE', 'SUPPLIER_TYPE_MOD', 'CONCAT_key', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'SERVICE_LEVEL', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION'] Numéricas (otras): []
Creamos un diccionario para determinar las variables que nos quedamo respecto las que eliminamos:
Nos quedamos con:
COUNTRY
ID_BUILDING
FM_COST_TYPE
MONTH
YEAR
cost_float_mod
SUPPLIER_TYPE_MOD_2
FM_RESPONSIBLE_MOD
ID_REGION
TIPO_USO
COUNTRY_CATALOGO (pendiente revisar si coincide)
ID_ORDER (solo la mantenenemos temporalmente en el dataset porque es la llave primaria y es una referencia de mucho valor para el equipo de FM. Una vez se acabe el analisis exploratorio, la eliminaremos junto a una de las los 2 variables pais).
Eliminamos por redundancia o no aportación de valor:
COST_TYPE
YEAR_MONTH
FM_RESPONSIBLE
COST
SUPPLIER_TYPE
SUPPLIER_TYPE_MOD
ID_BUSINESS_UNIT
bu_corr_flag
CONCAT_key
cost_float
QUARTER
ID_CUSTOMER
ID_SUPPLIER
ID_BUSINESS_UNIT_MOD
SERVICE_LEVEL
# === DICCIONARIO DE VARIABLES v5 ===
vars_v5 = {
"keep": [
"ID_ORDER" # despues de la AED la eliminaremos
"COUNTRY",
"ID_BUILDING",
"FM_COST_TYPE",
"MONTH",
"YEAR",
"cost_float_mod",
"SUPPLIER_TYPE_MOD_2",
"FM_RESPONSIBLE_MOD",
"ID_REGION",
"TIPO_USO",
"COUNTRY_CATALOGO" # pendiente revisar si coincide
],
"drop": [
"COST_TYPE",
"YEAR_MONTH",
"FM_RESPONSIBLE",
"COST",
"SUPPLIER_TYPE",
"SUPPLIER_TYPE_MOD",
"ID_BUSINESS_UNIT",
"bu_corr_flag",
"CONCAT_key",
"cost_float",
"QUARTER",
"ID_CUSTOMER",
"ID_SUPPLIER",
"ID_BUSINESS_UNIT_MOD",
"SERVICE_LEVEL",
]
}
print("=== Diccionario de variables v5 ===")
print(vars_v5)
=== Diccionario de variables v5 === {'keep': ['ID_ORDERCOUNTRY', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'ID_REGION', 'TIPO_USO', 'COUNTRY_CATALOGO'], 'drop': ['COST_TYPE', 'YEAR_MONTH', 'FM_RESPONSIBLE', 'COST', 'SUPPLIER_TYPE', 'SUPPLIER_TYPE_MOD', 'ID_BUSINESS_UNIT', 'bu_corr_flag', 'CONCAT_key', 'cost_float', 'QUARTER', 'ID_CUSTOMER', 'ID_SUPPLIER', 'ID_BUSINESS_UNIT_MOD', 'SERVICE_LEVEL']}
Conclusión del equipo FM (pendiente)¶
Antes de eliminar una de las dos variables de país, vamos a revisar si la columna COUNTRY
y COUNTRY_CATALOGO
son coincidentes al 100%. Deberían serlo.
A continuación vamos a realizar la comprobación y en caso de detectar diferencias, exportaremos el fichero de las diferencias al Drive para preguntar al equipo de FM y que nos digan que es lo que tenemos que hacer con estos 2,248 registros incoherentes entre el valor del país asociado a la Orden de compra (COUNTRY
) y la del pais según el espacio asociado a la orden de compra (COUNTRY_CATALOGO
).
# Detectamos las discrepancias entre COUNTRY y COUNTRY_CATALOGO, las listamos
# y las exportamos a Drive en formato Excel
# Verificamos que existan las columnas requeridas
cols_req = ["COUNTRY", "COUNTRY_CATALOGO", "ID_BUILDING"]
faltantes = [c for c in cols_req if c not in df_fd1_v4_opex.columns]
if faltantes:
raise KeyError(f"Faltan columnas en df_fd1_v4_opex: {faltantes}")
# Creamos columnas normalizadas para comparar (sin espacios y en mayúsculas)
country_norm = df_fd1_v4_opex["COUNTRY"].astype(str).str.strip().str.upper()
country_cat_norm = df_fd1_v4_opex["COUNTRY_CATALOGO"].astype(str).str.strip().str.upper()
# Generamos la máscara de diferencias
mask_diff = country_norm != country_cat_norm
# Construimos el dataset de diferencias con columnas clave
cols_salida = ["ID_BUILDING", "COUNTRY", "COUNTRY_CATALOGO"]
if "ID_ORDER" in df_fd1_v4_opex.columns:
cols_salida = ["ID_BUILDING", "ID_ORDER", "COUNTRY", "COUNTRY_CATALOGO", "FM_RESPONSIBLE_MOD"]
df_diff_country = df_fd1_v4_opex.loc[mask_diff, cols_salida].copy()
# Visualizamos un resumen por pantalla
total = len(df_fd1_v4_opex)
num_diff = len(df_diff_country)
print("=== COMPARACIÓN COUNTRY vs COUNTRY_CATALOGO ===")
print(f"Total registros: {total:,}")
print(f"Diferencias detectadas: {num_diff:,} ({(100*num_diff/total):.2f}%)")
=== COMPARACIÓN COUNTRY vs COUNTRY_CATALOGO === Total registros: 306,775 Diferencias detectadas: 2,248 (0.73%)
Exportamos a Drive del listado de registros con COUNTRY
distinto a COUNTRY_CATALOGO
¶
# Exportamos a Excel
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
ruta_excel = f"{ruta_out}/df_v4_opex_diferencias_COUNTRY_vs_CATALOGO.xlsx"
# Exportamos usando openpyxl
with pd.ExcelWriter(ruta_excel, engine="openpyxl") as writer:
df_diff_country.to_excel(writer, index=False, sheet_name="diferencias")
print(f"Archivo exportado correctamente a: {ruta_excel}")
Mientras esperamos la respuesta, procedemos a eliminar las variables según el diccionario definido.
Limpieza del df_fd1_v4_opex para dejarlo con una dimensionalidad óptima para modelizar a través de ML (Machine Learning) y/o ST (Series Temporales)¶
# Eliminamos las variables redundantes y nos quedarnos con la estructura
# final definida
# Creamos df_fd1_v5 eliminando las columnas en vars_v5["drop"]
df_fd1_v5 = df_fd1_v4_opex.drop(columns=vars_v5["drop"], errors="ignore").copy()
# Verificamos que mantenemos todas las columnas clave de vars_v5["keep"]
cols_presentes = [c for c in vars_v5["keep"] if c in df_fd1_v5.columns]
cols_faltantes = [c for c in vars_v5["keep"] if c not in df_fd1_v5.columns]
print("=== RESUMEN CREACIÓN v5 ===")
print(f"Filas: {len(df_fd1_v5):,}")
print(f"Columnas: {len(df_fd1_v5.columns):,}")
print("\nColumnas presentes (keep):", cols_presentes)
print("Columnas faltantes (keep):", cols_faltantes)
# Visualizamos el dataset resultante
display(df_fd1_v5.head(5))
=== RESUMEN CREACIÓN v5 === Filas: 306,775 Columnas: 12 Columnas presentes (keep): ['ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'ID_REGION', 'TIPO_USO', 'COUNTRY_CATALOGO'] Columnas faltantes (keep): ['ID_ORDERCOUNTRY']
ID_ORDER | COUNTRY | ID_BUILDING | FM_COST_TYPE | MONTH | YEAR | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | ID_REGION | TIPO_USO | COUNTRY_CATALOGO | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | F001108989 | España | 1001131 | Mtto. Contratos | 11 | 2021 | 77.00 | EXTERNO | Mantenimiento | 8 | Salón de Juego | España |
1 | F001119565 | España | 1000026 | Mtto. Contratos | 7 | 2021 | 36.95 | EXTERNO | Mantenimiento | 23 | Salón de Juego | España |
2 | F001119567 | España | 1000515 | Mtto. Contratos | 7 | 2021 | 36.95 | EXTERNO | Mantenimiento | 32 | Salón de Juego | España |
3 | F001120426 | España | 9 | Mtto. Contratos | 1 | 2021 | 605.45 | EXTERNO | Mantenimiento | 2 | Oficinas | España |
4 | F001120655 | España | 116 | Mtto. Contratos | 2 | 2021 | 277.19 | EXTERNO | Mantenimiento | 17 | Bingo | España |
Segundo Analisis Exploratorio de Datos¶
Vamos ahora con el Analisis Exploratorio de datos del nuevo dataset versión 5 de Opex.
# Analizar cost_float_mod y revisar categorías de las variables categóricas
# Definimos la variable dependiente (numérica)
var_target = "cost_float_mod"
print("=== Estadísticos de cost_float_mod ===")
display(df_fd1_v5[var_target].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]).to_frame())
# Boxplot para cost_float_mod
plt.figure(figsize=(8, 3))
plt.boxplot(df_fd1_v5[var_target].dropna(), vert=False)
plt.title("Boxplot de cost_float_mod")
plt.xlabel(var_target)
plt.show()
# Variables categóricas a analizar
vars_categoricas = [
"COUNTRY", "FM_COST_TYPE", "SUPPLIER_TYPE_MOD_2", "FM_RESPONSIBLE_MOD",
"ID_REGION", "TIPO_USO", "COUNTRY_CATALOGO"
]
vars_categoricas = [c for c in vars_categoricas if c in df_fd1_v5.columns]
print("\n=== ANÁLISIS DE VARIABLES CATEGÓRICAS ===")
for col in vars_categoricas:
print(f"\n--- {col} ---")
print(f"Nº de categorías: {df_fd1_v5[col].nunique()}")
print(df_fd1_v5[col].value_counts(dropna=False).head(10)) # top 10 categorías más frecuentes
=== Estadísticos de cost_float_mod ===
cost_float_mod | |
---|---|
count | 306775.000000 |
mean | 583.043000 |
std | 2271.779234 |
min | -40880.070000 |
5% | 0.000000 |
25% | 0.000000 |
50% | 47.727273 |
75% | 233.306000 |
95% | 2983.501250 |
max | 118503.992000 |
=== ANÁLISIS DE VARIABLES CATEGÓRICAS === --- COUNTRY --- Nº de categorías: 7 COUNTRY España 115099 Colombia 74909 Perú 34859 Panamá 29055 México 28200 Costa Rica 14172 República Dominicana 10481 Name: count, dtype: int64 --- FM_COST_TYPE --- Nº de categorías: 8 FM_COST_TYPE Mtto. Correctivo 176085 Suministros 42040 Servicios Extra 35263 Servicios Ctto. 27345 Mtto. Contratos 15766 Licencias 4510 Obras 4031 Eficiencia Energética 1735 Name: count, dtype: int64 --- SUPPLIER_TYPE_MOD_2 --- Nº de categorías: 2 SUPPLIER_TYPE_MOD_2 EXTERNO 199957 INTERNO 106818 Name: count, dtype: int64 --- FM_RESPONSIBLE_MOD --- Nº de categorías: 5 FM_RESPONSIBLE_MOD Mantenimiento 252512 Eficiencia Energética 44441 Licencias 5596 Obras Proyectos 2380 Gestión Espacios 1846 Name: count, dtype: int64 --- ID_REGION --- Nº de categorías: 104 ID_REGION 2 26578 1000022 25459 103 23785 94 23041 105 19484 8 16130 117 12449 17 11958 32 8727 1000039 8725 Name: count, dtype: int64 --- TIPO_USO --- Nº de categorías: 14 TIPO_USO Casino Electrónico 106781 Casino Tradicional 104602 Salón de Juego 35625 Bingo 25517 Oficinas 14727 Delegación 10037 Industrial 3385 Almacén/Bodega 2332 Hotel 1967 Sin actividad 655 Name: count, dtype: int64 --- COUNTRY_CATALOGO --- Nº de categorías: 7 COUNTRY_CATALOGO España 114607 Colombia 75141 Perú 34859 Panamá 30811 México 28460 Costa Rica 14172 República Dominicana 8725 Name: count, dtype: int64
La variable ID_REGION
parece que tiene una alta cardinalidad (104 clases) y en un principio vamos debemos profundizar más para saber que tratamiento vamos a usar para el modelado.
# Calculamos frecuencia y media de cost_float_mod por región
# Agrupamos por ID_REGION
df_region_stats = (
df_fd1_v5.groupby("ID_REGION")
.agg(
num_registros=("cost_float_mod", "size"),
media_coste=("cost_float_mod", "mean")
)
.reset_index()
.sort_values("num_registros", ascending=False)
)
# Visualizamos el resumen general
print("=== RESUMEN ID_REGION ===")
print(f"Nº de regiones distintas: {df_region_stats.shape[0]}")
print(f"Registros totales: {df_region_stats['num_registros'].sum():,}")
# Visualizamos el dataset
display(df_region_stats.head(15))
# Calculamos los estadísticos de frecuencia de regiones
print("\n=== DISTRIBUCIÓN DE FRECUENCIAS (nº de registros por región) ===")
print(df_region_stats["num_registros"].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]))
=== RESUMEN ID_REGION === Nº de regiones distintas: 104 Registros totales: 306,775
/tmp/ipython-input-1167182373.py:5: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning. df_fd1_v5.groupby("ID_REGION")
ID_REGION | num_registros | media_coste | |
---|---|---|---|
0 | 2 | 26578 | 566.162446 |
78 | 1000022 | 25459 | 187.959776 |
52 | 103 | 23785 | 272.650425 |
45 | 94 | 23041 | 1426.885862 |
54 | 105 | 19484 | 377.718900 |
6 | 8 | 16130 | 357.345006 |
64 | 117 | 12449 | 280.291381 |
14 | 17 | 11958 | 337.825073 |
26 | 32 | 8727 | 479.636750 |
93 | 1000039 | 8725 | 758.855437 |
1 | 3 | 7571 | 228.657442 |
67 | 1000005 | 6830 | 179.102905 |
79 | 1000023 | 6027 | 215.830859 |
41 | 48 | 5604 | 309.991795 |
3 | 5 | 4162 | 390.981887 |
=== DISTRIBUCIÓN DE FRECUENCIAS (nº de registros por región) === count 104.000000 mean 2949.759615 std 5406.722998 min 11.000000 5% 177.150000 25% 538.000000 50% 958.000000 75% 2352.000000 95% 15577.850000 max 26578.000000 Name: num_registros, dtype: float64
Este resultado nos dice mucho:
Hay 104 regiones distintas en total.
El mínimo de registros en una región es 11.
El 25% de las regiones tienen menos de 538 registros → muchas regiones poco representadas.
El 50% (mediana) tiene menos de 958 registros.
Solo unas pocas regiones concentran la mayoría de los datos (ej. la región 2 con 26.578 registros).
Creamos la variable ID_REGION_GROUP
(pendiente)¶
Vamos a realizar una nueva columna ID_REGION_GROUP
en las que agruparemos regiones de un mismo país con menos de 500 registros, de esta manera reduciremos la cardinalidad sin perder tanta información.
# Creamos ID_REGION_GRUPO, donde las regiones con pocos registros de un mismo pais (< umbral) se agrupan como "Otros_{COUNTRY}"
# Definimos el umbral de frecuencia mínima por país
umbral = 1000 # ajustamos de 500 a 1000 para conseguir una cardinalidad aceptable (< 55 para OHE)
# Calculamos la frecuencia por (COUNTRY, ID_REGION)
freq_por_pais_region = (
df_fd1_v5
.groupby(["COUNTRY", "ID_REGION"], dropna=False)
.size()
.reset_index(name="n")
)
# Creamos un diccionario de frecuencias para consulta rápida
# Clave: (COUNTRY, ID_REGION) -> Valor: n
freq_map = {(row["COUNTRY"], row["ID_REGION"]): row["n"] for _, row in freq_por_pais_region.iterrows()}
# Función para asignar el grupo
def asignar_grupo(country, id_region):
# Si falta el país o la región, devolvemos NA de forma segura
if pd.isna(country) or pd.isna(id_region):
return np.nan
# Buscamos en el diccionario 'freq_map' cuántos registros hay para esa combinación (COUNTRY, ID_REGION).
# Si no está, devolvemos 0 por defecto.
n = freq_map.get((country, id_region), 0)
# Si el número de registros de esa región está por debajo del umbral definido,
# agrupamos esa región dentro de una categoría "Otros_{COUNTRY}".
if n < umbral:
return f"Otros_{country}"
else:
# Si está por encima del umbral, dejamos la región como está.
return str(id_region)
# Creamos la nueva columna ID_REGION_GRUPO
df_fd1_v5["ID_REGION_GRUPO"] = df_fd1_v5.apply(
lambda r: asignar_grupo(r["COUNTRY"], r["ID_REGION"]),
axis=1
).astype("category")
# Visualizamos el resumen de la operación
num_regiones = freq_por_pais_region.shape[0]
num_regiones_poco_repr = (freq_por_pais_region["n"] < umbral).sum()
num_labels_finales = df_fd1_v5["ID_REGION_GRUPO"].nunique()
print("=== AGRUPACIÓN DE REGIONES POR PAÍS ===")
print(f"Umbral: {umbral} registros por (COUNTRY, ID_REGION)")
print(f"Total de pares (COUNTRY, ID_REGION): {num_regiones}")
print(f"Pares por debajo del umbral que agrupamos: {num_regiones_poco_repr}")
print(f"Nº de etiquetas finales en ID_REGION_GRUPO: {num_labels_finales}")
# Visualizamos las etiquetas más frecuentes
print("\n=== FRECUENCIAS DE ID_REGION_GRUPO (top 15) ===")
print(df_fd1_v5["ID_REGION_GRUPO"].value_counts().head(15))
# Definimos una tabla de referencia de cómo hemos agrupado
tabla_grupos = (
df_fd1_v5
.groupby(["COUNTRY", "ID_REGION", "ID_REGION_GRUPO"], dropna=False)
.size()
.reset_index(name="n_registros")
.sort_values(["COUNTRY", "ID_REGION_GRUPO", "n_registros"], ascending=[True, True, False])
)
display(tabla_grupos.head(20))
/tmp/ipython-input-626660888.py:9: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning. .groupby(["COUNTRY", "ID_REGION"], dropna=False)
=== AGRUPACIÓN DE REGIONES POR PAÍS === Umbral: 1000 registros por (COUNTRY, ID_REGION) Total de pares (COUNTRY, ID_REGION): 728 Pares por debajo del umbral que agrupamos: 678 Nº de etiquetas finales en ID_REGION_GRUPO: 55 === FRECUENCIAS DE ID_REGION_GRUPO (top 15) === ID_REGION_GRUPO 2 26578 1000022 25459 103 23785 94 23041 105 19484 8 16130 Otros_España 13505 117 12449 17 11958 Otros_México 8832 32 8727 1000039 8725 3 7571 1000005 6830 1000023 6027 Name: count, dtype: int64
/tmp/ipython-input-626660888.py:61: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning. .groupby(["COUNTRY", "ID_REGION", "ID_REGION_GRUPO"], dropna=False)
COUNTRY | ID_REGION | ID_REGION_GRUPO | n_registros | |
---|---|---|---|---|
0 | Colombia | 2 | 100 | 0 |
55 | Colombia | 3 | 100 | 0 |
110 | Colombia | 4 | 100 | 0 |
165 | Colombia | 5 | 100 | 0 |
220 | Colombia | 6 | 100 | 0 |
275 | Colombia | 7 | 100 | 0 |
330 | Colombia | 8 | 100 | 0 |
385 | Colombia | 9 | 100 | 0 |
440 | Colombia | 10 | 100 | 0 |
495 | Colombia | 11 | 100 | 0 |
550 | Colombia | 12 | 100 | 0 |
605 | Colombia | 14 | 100 | 0 |
660 | Colombia | 15 | 100 | 0 |
715 | Colombia | 16 | 100 | 0 |
770 | Colombia | 17 | 100 | 0 |
825 | Colombia | 18 | 100 | 0 |
880 | Colombia | 19 | 100 | 0 |
935 | Colombia | 20 | 100 | 0 |
990 | Colombia | 21 | 100 | 0 |
1045 | Colombia | 22 | 100 | 0 |
Exportación a Excel de la clasificación realizado relacionada¶
# Guardamos la tabla de referencia con correspondencias por país
# y un resumen con el nº de registros por grupo
# Definimos ruta de salida
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
ruta_excel_tabla = f"{ruta_out}/v5_id_region_grupos.xlsx"
# Tabla de correspondencias ya calculada previamente -> tabla_grupos
# Contiene: COUNTRY, ID_REGION, ID_REGION_GRUPO, n_registros
# Generamos el resumen de frecuencias por grupo
resumen_grupo = (
df_fd1_v5["ID_REGION_GRUPO"]
.value_counts(dropna=False)
.rename_axis("ID_REGION_GRUPO")
.reset_index(name="n_registros")
)
# Exportamos ambos a un archivo Excel con dos hojas
with pd.ExcelWriter(ruta_excel_tabla, engine="openpyxl") as writer:
tabla_grupos.to_excel(writer, index=False, sheet_name="agrupacion")
resumen_grupo.to_excel(writer, index=False, sheet_name="resumen_grupos")
print("=== EXPORTACIÓN COMPLETA ===")
print(f"Archivo generado: {ruta_excel_tabla}")
print(f"Filas en tabla de correspondencias: {len(tabla_grupos):,}")
print(f"Filas en resumen de grupos: {len(resumen_grupo):,}")
=== EXPORTACIÓN COMPLETA === Archivo generado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/v5_id_region_grupos.xlsx Filas en tabla de correspondencias: 40,040 Filas en resumen de grupos: 55
Dejamos el umbral en 1000 registros para conseguir solo 55 clases en ID_REGION_GRUPO
.
Eliminamos ID_REGION
y dejamos la nueva ID_REGION_GRUPO
¶
# Añadimos ID_REGION_GRUPO y eliminamos la ID_REGION original
# Verificamos que ambas columnas existan
if "ID_REGION_GRUPO" not in df_fd1_v5.columns:
raise KeyError("No existe la columna 'ID_REGION_GRUPO'. Debemos recalcularla antes.")
if "ID_REGION" not in df_fd1_v5.columns:
raise KeyError("No existe la columna original 'ID_REGION'. Ya podría haberse eliminado.")
# Eliminamos la columna original ID_REGION
df_fd1_v5 = df_fd1_v5.drop(columns=["ID_REGION"])
# Nos aseguramos de que ID_REGION_GRUPO sea categórica
df_fd1_v5["ID_REGION_GRUPO"] = df_fd1_v5["ID_REGION_GRUPO"].astype("category")
# Visualizamos el resumen de verificación
print("=== RESUMEN DE ID_REGION_GRUPO ===")
print(f"Columnas actuales: {df_fd1_v5.columns.tolist()}")
print(f"Nº de clases en ID_REGION_GRUPO: {df_fd1_v5['ID_REGION_GRUPO'].nunique()}")
print("\nTop 15 categorías por frecuencia:")
print(df_fd1_v5['ID_REGION_GRUPO'].value_counts().head(15))
=== RESUMEN DE ID_REGION_GRUPO === Columnas actuales: ['ID_ORDER', 'COUNTRY', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO'] Nº de clases en ID_REGION_GRUPO: 55 Top 15 categorías por frecuencia: ID_REGION_GRUPO 2 26578 1000022 25459 103 23785 94 23041 105 19484 8 16130 Otros_España 13505 117 12449 17 11958 Otros_México 8832 32 8727 1000039 8725 3 7571 1000005 6830 1000023 6027 Name: count, dtype: int64
Análisis de los outliers de cost_float_mod
¶
Finalmente nos enfocaremos en los outliers de cost_float_mod
.
Para ello vamos a usar el criterio del rango intercuartílico (IQR):
Calculamos Q1 (25%) y Q3 (75%).
IQR = Q3 - Q1.
Definimos outliers como valores < Q1 - 1.5·IQR o > Q3 + 1.5·IQR.
# === DETECCIÓN DE OUTLIERS EN cost_float_mod ===
# Calculamos cuartiles e IQR
Q1 = df_fd1_v5["cost_float_mod"].quantile(0.25)
Q3 = df_fd1_v5["cost_float_mod"].quantile(0.75)
IQR = Q3 - Q1
limite_inferior = Q1 - 1.5 * IQR
limite_superior = Q3 + 1.5 * IQR
print("=== UMBRALES DE OUTLIERS (IQR) ===")
print(f"Q1 (25%): {Q1:.2f}")
print(f"Q3 (75%): {Q3:.2f}")
print(f"IQR: {IQR:.2f}")
print(f"Límite inferior: {limite_inferior:.2f}")
print(f"Límite superior: {limite_superior:.2f}")
# Creamos máscara de outliers
mask_outliers = (df_fd1_v5["cost_float_mod"] < limite_inferior) | (df_fd1_v5["cost_float_mod"] > limite_superior)
# Visualizamos el resumen
num_outliers = mask_outliers.sum()
total = len(df_fd1_v5)
print(f"\nOutliers detectados: {num_outliers:,} ({100*num_outliers/total:.2f}%) de {total:,} registros")
# Generamos el dataset de outliers
df_outliers = df_fd1_v5.loc[mask_outliers, ["ID_ORDER", "ID_BUILDING",
"FM_RESPONSIBLE_MOD", "FM_COST_TYPE",
"COUNTRY", "ID_REGION_GRUPO", "TIPO_USO",
"cost_float_mod"]].copy()
# Vista rápida de los valores extremos
print("\nEjemplos de outliers:")
display(df_outliers.sort_values("cost_float_mod", ascending=False).head(10))
=== UMBRALES DE OUTLIERS (IQR) === Q1 (25%): 0.00 Q3 (75%): 233.31 IQR: 233.31 Límite inferior: -349.96 Límite superior: 583.26 Outliers detectados: 42,174 (13.75%) de 306,775 registros Ejemplos de outliers:
ID_ORDER | ID_BUILDING | FM_RESPONSIBLE_MOD | FM_COST_TYPE | COUNTRY | ID_REGION_GRUPO | TIPO_USO | cost_float_mod | |
---|---|---|---|---|---|---|---|---|
62654 | F001212407 | 1211 | Eficiencia Energética | Suministros | Colombia | 105 | Casino Tradicional | 118503.9920 |
243570 | 701824718 | 1000270 | Mantenimiento | Servicios Extra | España | 18 | Bingo | 112260.5700 |
80793 | F001234850 | 1000451 | Eficiencia Energética | Suministros | México | 1000031 | Casino Tradicional | 111152.7625 |
61310 | F001210442 | 1211 | Eficiencia Energética | Suministros | Colombia | 105 | Casino Tradicional | 108664.5720 |
80792 | F001234849 | 1000451 | Eficiencia Energética | Suministros | México | 1000031 | Casino Tradicional | 108252.6065 |
80667 | F001234679 | 1000399 | Eficiencia Energética | Suministros | México | 1000015 | Casino Electrónico | 96920.3400 |
80666 | F001234678 | 1000399 | Eficiencia Energética | Suministros | México | 1000015 | Casino Electrónico | 93819.8780 |
62667 | F001212424 | 1000252 | Eficiencia Energética | Suministros | Colombia | 105 | Casino Tradicional | 87107.7480 |
61323 | F001210455 | 1000252 | Eficiencia Energética | Suministros | Colombia | 105 | Casino Tradicional | 80294.9750 |
62683 | F001212450 | 1213 | Eficiencia Energética | Suministros | Colombia | 105 | Casino Tradicional | 79072.5610 |
Exportamos a Drive los outliers¶
# Definimos ruta de salida
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
ruta_excel_outliers = f"{ruta_out}/df_v5_outliers_cost_float_mod.xlsx"
# Exportamos a Excel
with pd.ExcelWriter(ruta_excel_outliers, engine="openpyxl") as writer:
df_outliers.to_excel(writer, index=False, sheet_name="outliers")
print("=== EXPORTACIÓN COMPLETA ===")
print(f"Archivo generado: {ruta_excel_outliers}")
print(f"Total de outliers exportados: {len(df_outliers):,}")
=== EXPORTACIÓN COMPLETA === Archivo generado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_v5_outliers_cost_float_mod.xlsx Total de outliers exportados: 42,174
Conclusión del equipo FM (Outliers cost_float_mod
)¶
Se verifican con el equipo de FM quienes nos argumentan que no se trata de errores, si no que son excepcionalidades realizadas durante la gestión que deben considerarse.
De momento mantinemos los outliers porque aportan valor en el año que aparecen (no en el mes)
NOTA:
Debemos interpretar bien la explicación que argumentan. Los casos de outliers generalmente representan regularizaciones de costes excesivos (corrección de errores de imputación) o bien se imputan costes adicionales de periodos anteriores que superan el mes. Parece que si podriamos considerar que son outliers, en el sentido que en el modelo de gestión de FM no se permite el reparto proporcional de las imputaciones cuando son correcciones (cargos o abonos) en el periodo en el que se realizó y por tanto devengó.
Esta revisión la dejamos para más adelante, si observamos un comportamiento del modelo que no se ajuste.
Comprobación de la unicidad de las clases de la variable ID_ORDER
¶
Comprobamos si cada ID_ORDER
aparece una sola vez en df_fd1_v5
. Es una verificación que deberíamos haber realizado al inicio, pero la hemos realizado ahora.
# Comprobamos si cada ID_ORDER aparece una sola vez en df_fd1_v5
# Calculamos el número de veces que aparece cada ID_ORDER
conteo_id_order = df_fd1_v5["ID_ORDER"].value_counts()
# Visualizamos un resumen
total_orders = len(df_fd1_v5["ID_ORDER"].unique())
duplicados = (conteo_id_order > 1).sum()
print("=== ANÁLISIS DE ID_ORDER EN v5 ===")
print(f"Total de ID_ORDER distintos: {total_orders:,}")
print(f"ID_ORDER con más de una aparición: {duplicados:,}")
# Visualizamos los los más repetidos
print("\n=== Ejemplos de ID_ORDER repetidos ===")
display(conteo_id_order[conteo_id_order > 1].head(20))
=== ANÁLISIS DE ID_ORDER EN v5 === Total de ID_ORDER distintos: 306,775 ID_ORDER con más de una aparición: 0 === Ejemplos de ID_ORDER repetidos ===
count | |
---|---|
ID_ORDER |
Análisis de los estadísticos de las variables de df_fd1_v5
¶
# Obtenemos estadísticas generales de todas las variables en df_fd1_v5
print("=== DESCRIBE DE VARIABLES NUMÉRICAS ===")
display(df_fd1_v5.describe().T)
print("\n=== DESCRIBE DE TODAS LAS VARIABLES (incluye categóricas) ===")
display(df_fd1_v5.describe(include="all").T)
print("\n=== INFO DEL DATAFRAME ===")
print(df_fd1_v5.info())
print("\n=== NULOS POR COLUMNA ===")
print(df_fd1_v5.isna().sum())
=== DESCRIBE DE VARIABLES NUMÉRICAS ===
count | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|
ID_BUILDING | 306775.0 | 501178.645929 | 499922.745257 | 2.00 | 1093.0 | 1.000004e+06 | 1000608.000 | 1001588.000 |
MONTH | 306775.0 | 6.308598 | 3.315537 | 1.00 | 3.0 | 6.000000e+00 | 9.000 | 12.000 |
YEAR | 306775.0 | 2023.123093 | 1.362546 | 2021.00 | 2022.0 | 2.023000e+03 | 2024.000 | 2025.000 |
cost_float_mod | 306775.0 | 583.043000 | 2271.779234 | -40880.07 | 0.0 | 4.772727e+01 | 233.306 | 118503.992 |
=== DESCRIBE DE TODAS LAS VARIABLES (incluye categóricas) ===
count | unique | top | freq | mean | std | min | 25% | 50% | 75% | max | |
---|---|---|---|---|---|---|---|---|---|---|---|
ID_ORDER | 306775 | 306775 | 701948957 | 1 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
COUNTRY | 306775 | 7 | España | 115099 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
ID_BUILDING | 306775.0 | NaN | NaN | NaN | 501178.645929 | 499922.745257 | 2.0 | 1093.0 | 1000004.0 | 1000608.0 | 1001588.0 |
FM_COST_TYPE | 306775 | 8 | Mtto. Correctivo | 176085 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
MONTH | 306775.0 | NaN | NaN | NaN | 6.308598 | 3.315537 | 1.0 | 3.0 | 6.0 | 9.0 | 12.0 |
YEAR | 306775.0 | NaN | NaN | NaN | 2023.123093 | 1.362546 | 2021.0 | 2022.0 | 2023.0 | 2024.0 | 2025.0 |
cost_float_mod | 306775.0 | NaN | NaN | NaN | 583.043 | 2271.779234 | -40880.07 | 0.0 | 47.727273 | 233.306 | 118503.992 |
SUPPLIER_TYPE_MOD_2 | 306775 | 2 | EXTERNO | 199957 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
FM_RESPONSIBLE_MOD | 306775 | 5 | Mantenimiento | 252512 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
TIPO_USO | 306775 | 14 | Casino Electrónico | 106781 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
COUNTRY_CATALOGO | 306775 | 7 | España | 114607 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
ID_REGION_GRUPO | 306775 | 55 | 2 | 26578 | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
=== INFO DEL DATAFRAME === <class 'pandas.core.frame.DataFrame'> RangeIndex: 306775 entries, 0 to 306774 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ID_ORDER 306775 non-null object 1 COUNTRY 306775 non-null object 2 ID_BUILDING 306775 non-null int64 3 FM_COST_TYPE 306775 non-null object 4 MONTH 306775 non-null int64 5 YEAR 306775 non-null int64 6 cost_float_mod 306775 non-null float64 7 SUPPLIER_TYPE_MOD_2 306775 non-null object 8 FM_RESPONSIBLE_MOD 306775 non-null object 9 TIPO_USO 306775 non-null object 10 COUNTRY_CATALOGO 306775 non-null object 11 ID_REGION_GRUPO 306775 non-null category dtypes: category(1), float64(1), int64(3), object(7) memory usage: 26.0+ MB None === NULOS POR COLUMNA === ID_ORDER 0 COUNTRY 0 ID_BUILDING 0 FM_COST_TYPE 0 MONTH 0 YEAR 0 cost_float_mod 0 SUPPLIER_TYPE_MOD_2 0 FM_RESPONSIBLE_MOD 0 TIPO_USO 0 COUNTRY_CATALOGO 0 ID_REGION_GRUPO 0 dtype: int64
Exportamos a Drive la v5 preparada para iniciar la segregación según la 1a iteración (2021-2023) y la 2a iteración (2021-2024).¶
# Guardamos la versión 5 del dataset preparada para la segregación de iteraciones
# Definimos ruta de salida
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# Exportamos en Excel
ruta_excel = f"{ruta_out}/df_fd1_v5.xlsx"
df_fd1_v5.to_excel(ruta_excel, index=False)
# Exportamos en CSV con separador ';'
ruta_csv = f"{ruta_out}/df_fd1_v5.csv"
df_fd1_v5.to_csv(ruta_csv, sep=";", index=False)
print("=== EXPORTACIÓN COMPLETA ===")
print(f"Archivo Excel: {ruta_excel}")
print(f"Archivo CSV : {ruta_csv}")
print(f"Filas: {len(df_fd1_v5):,} | Columnas: {len(df_fd1_v5.columns):,}")
=== EXPORTACIÓN COMPLETA === Archivo Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v5.xlsx Archivo CSV : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_fd1_v5.csv Filas: 306,775 | Columnas: 12
PREPARACION Y AJUSTE DEL DATASET PARA LA FASE DE ENTRENAMIENTO¶
Cargamos el dataset df_fd1_v5
del Drive¶
# Recuperamos el dataset v5 guardado previamente
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# Cargamos desde Excel
df_fd1_v5 = pd.read_excel(f"{ruta_out}/df_fd1_v5.xlsx")
# Alternativa: cargar desde CSV con separador ';'
# df_fd1_v5 = pd.read_csv(f"{ruta_out}/df_fd1_v5.csv", sep=";")
print("=== DATASET CARGADO ===")
print(f"Filas: {len(df_fd1_v5):,} | Columnas: {len(df_fd1_v5.columns):,}")
print("Columnas:", df_fd1_v5.columns.tolist())
=== DATASET CARGADO === Filas: 306,775 | Columnas: 12 Columnas: ['ID_ORDER', 'COUNTRY', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO']
Eliminamos variables sin valor en la modelización¶
Eliminamos las variables que no aportan valor:
ID_ORDER
COUNTRY
(es una hipótesis, debemos esperar la respuesta del equipo FM).
Como puede ser que finalmente el equipo de FM concluya que nos debemos quedar con COUNTRY
en lugar de COUNTRY_CATALOGO
, vamos a definir una nueva variable COUNTRY_DEF
que toma el valor de los registros de la variable que se ha quedado en el dataset, que pueden ser ambas pero nunca las dos a la vez.
# Quedarnos con COUNTRY o COUNTRY_CATALOGO (según decisión) y
# renombrarla a COUNTRY_DEF.
# Definimos los parámetros de decisión
# - Establecemos cuál preferimos conservar: "COUNTRY" o "COUNTRY_CATALOGO"
prefer_pais = "COUNTRY_CATALOGO" # cambiaremos a "COUNTRY" si decidimos usar la de la v3.
# - Indicamos si queremos eliminar la columna alternativa una vez creada COUNTRY_DEF
drop_columna_alternativa = True
# Verificamos qué columnas están disponibles
cols_pais = [c for c in ["COUNTRY", "COUNTRY_CATALOGO"] if c in df_fd1_v5.columns]
if not cols_pais:
raise KeyError("No encontramos ni 'COUNTRY' ni 'COUNTRY_CATALOGO' en df_fd1_v5.")
# Determinamos la columna a conservar como COUNTRY_DEF
if prefer_pais in cols_pais:
col_keep = prefer_pais
else:
# Si la preferida no existe, usamos la disponible
col_keep = cols_pais[0]
# Creamos/renombramos COUNTRY_DEF a partir de la columna elegida
df_fd1_v5["COUNTRY_DEF"] = df_fd1_v5[col_keep]
# Eliminamos la columna alternativa si procede
col_alternativa = None
for c in ["COUNTRY", "COUNTRY_CATALOGO"]:
if c in df_fd1_v5.columns and c != col_keep:
col_alternativa = c
break
if drop_columna_alternativa and col_alternativa is not None:
df_fd1_v5.drop(columns=[col_alternativa], inplace=True)
# Convertimos a categórica para dejar clara su naturaleza
df_fd1_v5["COUNTRY_DEF"] = df_fd1_v5["COUNTRY_DEF"].astype("category")
# Visualización de resumen
print("=== NORMALIZACIÓN DE PAÍS ===")
print(f"Preferencia solicitada: {prefer_pais}")
print(f"Columna utilizada para COUNTRY_DEF: {col_keep}")
if col_alternativa:
print(f"Columna alternativa detectada: {col_alternativa} | Eliminada: {drop_columna_alternativa}")
print("Valores únicos en COUNTRY_DEF:", df_fd1_v5["COUNTRY_DEF"].cat.categories.tolist())
=== NORMALIZACIÓN DE PAÍS === Preferencia solicitada: COUNTRY_CATALOGO Columna utilizada para COUNTRY_DEF: COUNTRY_CATALOGO Columna alternativa detectada: COUNTRY | Eliminada: True Valores únicos en COUNTRY_DEF: ['Colombia', 'Costa Rica', 'España', 'México', 'Panamá', 'Perú', 'República Dominicana']
Generamos las versiones para cada iteración.¶
Recordemos las 2 iteraciones que queremos realizar:
ITERACIÓN 1 (_ITE1): Historico de años entrenamiento -> 2021-2023
df_fd1_v5_ITE1
ITERACIÓN 2 (_ITE2): Historico de años entrenamiento -> 2021-2024
df_fd1_v5_ITE2
# Creamod df_fd1_v5_ITE1 (2021-2023) y df_fd1_v5_ITE2 (2021-2024)
# Revisamos estructura correcta
if "YEAR" not in df_fd1_v5.columns:
raise KeyError("No encontramos la columna 'YEAR' en df_fd1_v5.")
# Forzamos YEAR a numérico por si viniese como string
df_fd1_v5["YEAR"] = pd.to_numeric(df_fd1_v5["YEAR"], errors="coerce")
# Definimos rangos de cada iteración
rango_ite1 = [2021, 2022, 2023]
rango_ite2 = [2021, 2022, 2023, 2024]
# Creamos los datasets de entrenamiento por iteración
df_fd1_v5_ITE1 = df_fd1_v5[df_fd1_v5["YEAR"].isin(rango_ite1)].copy()
df_fd1_v5_ITE2 = df_fd1_v5[df_fd1_v5["YEAR"].isin(rango_ite2)].copy()
# Visualizamos resumen de cada iteración
print("=== RESUMEN ITERACIÓN 1 (2021-2023) ===")
print(f"Filas: {len(df_fd1_v5_ITE1):,} | Columnas: {len(df_fd1_v5_ITE1.columns):,}")
print("Años incluidos:", sorted(df_fd1_v5_ITE1['YEAR'].dropna().unique().tolist()))
print(df_fd1_v5_ITE1['YEAR'].value_counts().sort_index())
print("\n=== RESUMEN ITERACIÓN 2 (2021-2024) ===")
print(f"Filas: {len(df_fd1_v5_ITE2):,} | Columnas: {len(df_fd1_v5_ITE2.columns):,}")
print("Años incluidos:", sorted(df_fd1_v5_ITE2['YEAR'].dropna().unique().tolist()))
print(df_fd1_v5_ITE2['YEAR'].value_counts().sort_index())
=== RESUMEN ITERACIÓN 1 (2021-2023) === Filas: 174,626 | Columnas: 12 Años incluidos: [2021, 2022, 2023] YEAR 2021 48106 2022 61034 2023 65486 Name: count, dtype: int64 === RESUMEN ITERACIÓN 2 (2021-2024) === Filas: 243,916 | Columnas: 12 Años incluidos: [2021, 2022, 2023, 2024] YEAR 2021 48106 2022 61034 2023 65486 2024 69290 Name: count, dtype: int64
Exportación a Drive de los dataset pre-entrenamiento de las iteraciones 1 y 2.¶
# Exportación a Drive
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# ITERACIÓN 1
df_fd1_v5_ITE1.to_excel(f"{ruta_out}/df_fd1_v5_ITE1.xlsx", index=False)
df_fd1_v5_ITE1.to_csv(f"{ruta_out}/df_fd1_v5_ITE1.csv", sep=";", index=False)
# ITERACIÓN 2
df_fd1_v5_ITE2.to_excel(f"{ruta_out}/df_fd1_v5_ITE2.xlsx", index=False)
df_fd1_v5_ITE2.to_csv(f"{ruta_out}/df_fd1_v5_ITE2.csv", sep=";", index=False)
print("\n=== EXPORTACIÓN COMPLETA ===")
print(f"Guardado en: {ruta_out}")
print("- df_fd1_v5_ITE1.xlsx / .csv")
print("- df_fd1_v5_ITE2.xlsx / .csv")
=== EXPORTACIÓN COMPLETA === Guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO - df_fd1_v5_ITE1.xlsx / .csv - df_fd1_v5_ITE2.xlsx / .csv
Ahora creamos los conjuntos de datasets con valores reales para contrastar las previsiones que se realicen de cada iteración, los nombre serán df_fd1_v5_Real_ITE1
(datos del año 2024) y df_fd1_v5_Real_ITE2
(datos del año 2025).
# Creamos df_fd1_v5_Real_ITE1 (2024) y df_fd1_v5_Real_ITE2 (2025)
# Revisamos estructura correcta
if "YEAR" not in df_fd1_v5.columns:
raise KeyError("No encontramos la columna 'YEAR' en df_fd1_v5.")
df_fd1_v5["YEAR"] = pd.to_numeric(df_fd1_v5["YEAR"], errors="coerce")
# Real ITE1 → año 2024
df_fd1_v5_Real_ITE1 = df_fd1_v5[df_fd1_v5["YEAR"] == 2024].copy()
# Real ITE2 → año 2025
df_fd1_v5_Real_ITE2 = df_fd1_v5[df_fd1_v5["YEAR"] == 2025].copy()
# Acotamos opcionalmente el año 2025 hasta agosto
acotar_2025_hasta_agosto = True # ponemos False cuando no queramos acotar
if acotar_2025_hasta_agosto and "MONTH" in df_fd1_v5_Real_ITE2.columns:
df_fd1_v5_Real_ITE2 = df_fd1_v5_Real_ITE2[pd.to_numeric(df_fd1_v5_Real_ITE2["MONTH"], errors="coerce") <= 8].copy()
# Visualizamos los resúmenes
print("=== RESUMEN Reales ITE1 (2024) ===")
print(f"Filas: {len(df_fd1_v5_Real_ITE1):,} | Columnas: {len(df_fd1_v5_Real_ITE1.columns):,}")
if not df_fd1_v5_Real_ITE1.empty and "MONTH" in df_fd1_v5_Real_ITE1.columns:
print("Meses incluidos:", sorted(pd.to_numeric(df_fd1_v5_Real_ITE1["MONTH"], errors="coerce").dropna().unique().tolist()))
print("\n=== RESUMEN Reales ITE2 (2025) ===")
print(f"Filas: {len(df_fd1_v5_Real_ITE2):,} | Columnas: {len(df_fd1_v5_Real_ITE2.columns):,}")
if not df_fd1_v5_Real_ITE2.empty and "MONTH" in df_fd1_v5_Real_ITE2.columns:
print("Meses incluidos:", sorted(pd.to_numeric(df_fd1_v5_Real_ITE2["MONTH"], errors="coerce").dropna().unique().tolist()))
=== RESUMEN Reales ITE1 (2024) === Filas: 69,290 | Columnas: 12 Meses incluidos: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] === RESUMEN Reales ITE2 (2025) === Filas: 57,932 | Columnas: 12 Meses incluidos: [1, 2, 3, 4, 5, 6, 7, 8]
Exportamos a Drive ambos conjuntos¶
# Exportamos a Drive
ruta_out = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# Conjunto de Reales ITE1 (2024)
df_fd1_v5_Real_ITE1.to_excel(f"{ruta_out}/df_fd1_v5_Real_ITE1_2024.xlsx", index=False)
df_fd1_v5_Real_ITE1.to_csv(f"{ruta_out}/df_fd1_v5_Real_ITE1_2024.csv", sep=";", index=False)
# Conjunto de Reales ITE2 (2025; hasta agosto)
sufijo_ite2 = "_2025_hasta_agosto" if acotar_2025_hasta_agosto else "_2025"
df_fd1_v5_Real_ITE2.to_excel(f"{ruta_out}/df_fd1_v5_Real_ITE2{sufijo_ite2}.xlsx", index=False)
df_fd1_v5_Real_ITE2.to_csv(f"{ruta_out}/df_fd1_v5_Real_ITE2{sufijo_ite2}.csv", sep=";", index=False)
print("\n=== EXPORTACIÓN COMPLETA ===")
print(f"- df_fd1_v5_Real_ITE1_2024.xlsx / .csv")
print(f"- df_fd1_v5_Real_ITE2{sufijo_ite2}.xlsx / .csv")
print(f"Ubicación: {ruta_out}")
=== EXPORTACIÓN COMPLETA === - df_fd1_v5_Real_ITE1_2024.xlsx / .csv - df_fd1_v5_Real_ITE2_2025_hasta_agosto.xlsx / .csv Ubicación: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO
Guardamos el notebook en formato html¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_con_Iteraciones_vs_Reales.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 1 image(s). [NbConvertApp] Writing 1133173 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_con_Iteraciones_vs_Reales.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_con_Iteraciones_vs_Reales.html Existe: True
Modelización con series temporales (ST) para la iteración 1.¶
Vamos a cargar el dataset df_fd1_v5_ITE1
y el df_fd1_v5_Real_ITE1_2024
y le vamos a realizar una última transformación para prepararlo como conjunto de entrenamiento (train) y prueba (test).
Cargamos datasets df_fd1_v5_ITE1 y df_fd1_v5_Real_ITE1_2024.¶
# Cargamos datasets df_fd1_v5_ITE1 y df_fd1_v5_Real_ITE1_2024
# Ruta base donde guardaste los outputs
ruta_base = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/"
# Dataset de entrenamiento (Iteración 1: 2021-2023)
df_fd1_v5_ITE1 = pd.read_csv(ruta_base + "df_fd1_v5_ITE1.csv", sep=";")
# Dataset de reales (Iteración 1: datos del 2024)
df_fd1_v5_Real_ITE1_2024 = pd.read_csv(ruta_base + "df_fd1_v5_Real_ITE1_2024.csv", sep=";")
# Confirmamos tamaños y columnas
print("ITE1 shape:", df_fd1_v5_ITE1.shape)
print("Real_ITE1_2024 shape:", df_fd1_v5_Real_ITE1_2024.shape)
print("\nColumnas ITE1:", df_fd1_v5_ITE1.columns.tolist())
print("Columnas Real_ITE1_2024:", df_fd1_v5_Real_ITE1_2024.columns.tolist())
ITE1 shape: (174626, 12) Real_ITE1_2024 shape: (69290, 12) Columnas ITE1: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF'] Columnas Real_ITE1_2024: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF']
Exploración de ambos datasets - verificación que son correctos¶
# Exploración de ambos datasets - verificación que son correctos
# Exploración rápida de df_fd1_v5_ITE1
print("=== Dataset de Entrenamiento (ITE1) ===")
print(df_fd1_v5_ITE1.info())
print("\nPrimeras filas:")
display(df_fd1_v5_ITE1.head())
print("\nDescripción estadística:")
display(df_fd1_v5_ITE1.describe(include='all'))
# Exploración rápida de df_fd1_v5_Real_ITE1_2024
print("\n=== Dataset de Reales (ITE1 - 2024) ===")
print(df_fd1_v5_Real_ITE1_2024.info())
print("\nPrimeras filas:")
display(df_fd1_v5_Real_ITE1_2024.head())
print("\nDescripción estadística:")
display(df_fd1_v5_Real_ITE1_2024.describe(include='all'))
# Comparamos tamaños y columnas
print("\nComparación de columnas:")
print("ITE1:", df_fd1_v5_ITE1.shape, "columnas:", df_fd1_v5_ITE1.columns.tolist())
print("Real_ITE1_2024:", df_fd1_v5_Real_ITE1_2024.shape, "columnas:", df_fd1_v5_Real_ITE1_2024.columns.tolist())
=== Dataset de Entrenamiento (ITE1) === <class 'pandas.core.frame.DataFrame'> RangeIndex: 174626 entries, 0 to 174625 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ID_ORDER 174626 non-null object 1 ID_BUILDING 174626 non-null int64 2 FM_COST_TYPE 174626 non-null object 3 MONTH 174626 non-null int64 4 YEAR 174626 non-null int64 5 cost_float_mod 174626 non-null float64 6 SUPPLIER_TYPE_MOD_2 174626 non-null object 7 FM_RESPONSIBLE_MOD 174626 non-null object 8 TIPO_USO 174626 non-null object 9 COUNTRY_CATALOGO 174626 non-null object 10 ID_REGION_GRUPO 174626 non-null object 11 COUNTRY_DEF 174626 non-null object dtypes: float64(1), int64(3), object(8) memory usage: 16.0+ MB None Primeras filas:
ID_ORDER | ID_BUILDING | FM_COST_TYPE | MONTH | YEAR | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | TIPO_USO | COUNTRY_CATALOGO | ID_REGION_GRUPO | COUNTRY_DEF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | F001108989 | 1001131 | Mtto. Contratos | 11 | 2021 | 77.00 | EXTERNO | Mantenimiento | Salón de Juego | España | 8 | España |
1 | F001119565 | 1000026 | Mtto. Contratos | 7 | 2021 | 36.95 | EXTERNO | Mantenimiento | Salón de Juego | España | Otros_España | España |
2 | F001119567 | 1000515 | Mtto. Contratos | 7 | 2021 | 36.95 | EXTERNO | Mantenimiento | Salón de Juego | España | 32 | España |
3 | F001120426 | 9 | Mtto. Contratos | 1 | 2021 | 605.45 | EXTERNO | Mantenimiento | Oficinas | España | 2 | España |
4 | F001120655 | 116 | Mtto. Contratos | 2 | 2021 | 277.19 | EXTERNO | Mantenimiento | Bingo | España | 17 | España |
Descripción estadística:
ID_ORDER | ID_BUILDING | FM_COST_TYPE | MONTH | YEAR | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | TIPO_USO | COUNTRY_CATALOGO | ID_REGION_GRUPO | COUNTRY_DEF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
count | 174626 | 1.746260e+05 | 174626 | 174626.000000 | 174626.000000 | 174626.000000 | 174626 | 174626 | 174626 | 174626 | 174626 | 174626 |
unique | 174626 | NaN | 8 | NaN | NaN | NaN | 2 | 5 | 14 | 7 | 55 | 7 |
top | 701760631 | NaN | Mtto. Correctivo | NaN | NaN | NaN | EXTERNO | Mantenimiento | Casino Electrónico | España | 2 | España |
freq | 1 | NaN | 98919 | NaN | NaN | NaN | 118758 | 144266 | 60846 | 67229 | 15889 | 67229 |
mean | NaN | 4.943516e+05 | NaN | 6.684005 | 2022.099527 | 554.836295 | NaN | NaN | NaN | NaN | NaN | NaN |
std | NaN | 4.998727e+05 | NaN | 3.401633 | 0.800366 | 2046.978716 | NaN | NaN | NaN | NaN | NaN | NaN |
min | NaN | 2.000000e+00 | NaN | 1.000000 | 2021.000000 | -40880.070000 | NaN | NaN | NaN | NaN | NaN | NaN |
25% | NaN | 1.086000e+03 | NaN | 4.000000 | 2021.000000 | 0.000000 | NaN | NaN | NaN | NaN | NaN | NaN |
50% | NaN | 1.572000e+03 | NaN | 7.000000 | 2022.000000 | 50.000000 | NaN | NaN | NaN | NaN | NaN | NaN |
75% | NaN | 1.000565e+06 | NaN | 10.000000 | 2023.000000 | 233.116000 | NaN | NaN | NaN | NaN | NaN | NaN |
max | NaN | 1.001488e+06 | NaN | 12.000000 | 2023.000000 | 75977.040000 | NaN | NaN | NaN | NaN | NaN | NaN |
=== Dataset de Reales (ITE1 - 2024) === <class 'pandas.core.frame.DataFrame'> RangeIndex: 69290 entries, 0 to 69289 Data columns (total 12 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ID_ORDER 69290 non-null object 1 ID_BUILDING 69290 non-null int64 2 FM_COST_TYPE 69290 non-null object 3 MONTH 69290 non-null int64 4 YEAR 69290 non-null int64 5 cost_float_mod 69290 non-null float64 6 SUPPLIER_TYPE_MOD_2 69290 non-null object 7 FM_RESPONSIBLE_MOD 69290 non-null object 8 TIPO_USO 69290 non-null object 9 COUNTRY_CATALOGO 69290 non-null object 10 ID_REGION_GRUPO 69290 non-null object 11 COUNTRY_DEF 69290 non-null object dtypes: float64(1), int64(3), object(8) memory usage: 6.3+ MB None Primeras filas:
ID_ORDER | ID_BUILDING | FM_COST_TYPE | MONTH | YEAR | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | TIPO_USO | COUNTRY_CATALOGO | ID_REGION_GRUPO | COUNTRY_DEF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | F001176754 | 1000395 | Suministros | 2 | 2024 | 9304.615385 | EXTERNO | Eficiencia Energética | Casino Tradicional | Costa Rica | 1000010 | Costa Rica |
1 | F001176801 | 9 | Servicios Ctto. | 3 | 2024 | 5040.000000 | EXTERNO | Obras Proyectos | Oficinas | España | 2 | España |
2 | F001180765 | 247 | Mtto. Contratos | 1 | 2024 | 80.000000 | EXTERNO | Mantenimiento | Salón de Juego | España | 11 | España |
3 | F001182650 | 1000270 | Mtto. Contratos | 4 | 2024 | 540.870000 | EXTERNO | Mantenimiento | Bingo | España | 18 | España |
4 | F001182684 | 1001152 | Mtto. Contratos | 1 | 2024 | 80.000000 | EXTERNO | Mantenimiento | Salón de Juego | España | 6 | España |
Descripción estadística:
ID_ORDER | ID_BUILDING | FM_COST_TYPE | MONTH | YEAR | cost_float_mod | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | TIPO_USO | COUNTRY_CATALOGO | ID_REGION_GRUPO | COUNTRY_DEF | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
count | 69290.0 | 6.929000e+04 | 69290 | 69290.000000 | 69290.0 | 69290.000000 | 69290 | 69290 | 69290 | 69290 | 69290 | 69290 |
unique | 69290.0 | NaN | 8 | NaN | NaN | NaN | 2 | 5 | 14 | 7 | 55 | 7 |
top | 701868793.0 | NaN | Mtto. Correctivo | NaN | NaN | NaN | EXTERNO | Mantenimiento | Casino Electrónico | España | 2 | España |
freq | 1.0 | NaN | 39979 | NaN | NaN | NaN | 44974 | 57764 | 23718 | 26090 | 6028 | 26090 |
mean | NaN | 5.141913e+05 | NaN | 6.474340 | 2024.0 | 599.582213 | NaN | NaN | NaN | NaN | NaN | NaN |
std | NaN | 4.997670e+05 | NaN | 3.434215 | 0.0 | 2571.563909 | NaN | NaN | NaN | NaN | NaN | NaN |
min | NaN | 2.000000e+00 | NaN | 1.000000 | 2024.0 | -12960.230000 | NaN | NaN | NaN | NaN | NaN | NaN |
25% | NaN | 1.096000e+03 | NaN | 4.000000 | 2024.0 | 0.000000 | NaN | NaN | NaN | NaN | NaN | NaN |
50% | NaN | 1.000028e+06 | NaN | 7.000000 | 2024.0 | 51.500000 | NaN | NaN | NaN | NaN | NaN | NaN |
75% | NaN | 1.000741e+06 | NaN | 9.000000 | 2024.0 | 228.373625 | NaN | NaN | NaN | NaN | NaN | NaN |
max | NaN | 1.001586e+06 | NaN | 12.000000 | 2024.0 | 118503.992000 | NaN | NaN | NaN | NaN | NaN | NaN |
Comparación de columnas: ITE1: (174626, 12) columnas: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF'] Real_ITE1_2024: (69290, 12) columnas: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF']
¡Todo está correcto! Podemos seguir.
Proponemos un piloto inicial para probar un modelo de series temporales básico sobre nuestros datos:
- Seleccionamos la serie
Cogemos una combinación concreta: por ejemplo, un ID_BUILDING
y un FM_COST_TYPE
.
Filtramos el dataset y creamos una serie mensual de costes (cost_float_mod
).
- Modelo base sencillo
Inicialmente probamos con:
Media móvil / naïve forecast: repetir el último valor o la media de los últimos n meses.
Holt-Winters (ExponentialSmoothing de statsmodels): que ya capta tendencia y estacionalidad, y sigue siendo un modelo bastante simple.
- Validamos
Conjunto entrenamiento: dataset
df_fd1_v5_ITE1
con datos de 2021 a 2023.Conjunto test: dataset
df_fd1_v5_Real_ITE1_2024
con datos reales de 2024.Medimos errores: RMSE, MAPE.
- Iteramos
Si funciona, vamos a probar Prophet y validación rolling.
Después, pasamos a la fase residual ML.
# === Definimos una linea base con Holt-Winters por (ID_BUILDING, FM_COST_TYPE)
# con evaluación de las previsiones contra reales del 2024 ===
# Definimos una primera combinación que deseamos modelar
ID_BUILDING_OBJETIVO = 1001131 # <-- ejemplo
FM_COST_TYPE_OBJETIVO = "Mtto. Contratos" # <-- ejemplo
# Preparamos función auxiliar para MAPE seguro (ignoramos reales=0 para evitar divisiones por cero)
def mape_safe(y_true, y_pred):
y_true = np.array(y_true, dtype=float)
y_pred = np.array(y_pred, dtype=float)
mask = y_true != 0
if mask.sum() == 0:
return np.nan, 0 # no podemos calcular MAPE si todos son 0
return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100.0, int((~mask).sum())
# Creamos columna de fecha (usamos el día 1 de cada mes) y agregamos por mes
def preparar_serie(df, id_building, cost_type):
# Filtramos por la combinación deseada
df_f = df[(df["ID_BUILDING"] == id_building) & (df["FM_COST_TYPE"] == cost_type)].copy()
# Creamos columna fecha y agregamos coste mensual (por si hay múltiples órdenes en el mismo mes)
df_f["FECHA"] = pd.to_datetime(dict(year=df_f["YEAR"], month=df_f["MONTH"], day=1))
serie_mensual = (
df_f.groupby("FECHA", as_index=True)["cost_float_mod"]
.sum()
.sort_index()
)
# Reindexamos a frecuencia mensual continua para asegurar meses sin datos (los ponemos a 0)
idx_completo = pd.period_range(serie_mensual.index.min(), serie_mensual.index.max(), freq="M").to_timestamp()
serie_mensual = serie_mensual.reindex(idx_completo, fill_value=0.0)
return serie_mensual
# Construimos serie de entrenamiento (2021-2023) y de test (2024)
serie_train = preparar_serie(df_fd1_v5_ITE1, ID_BUILDING_OBJETIVO, FM_COST_TYPE_OBJETIVO)
serie_test = preparar_serie(df_fd1_v5_Real_ITE1_2024, ID_BUILDING_OBJETIVO, FM_COST_TYPE_OBJETIVO)
# Nos aseguramos de que las fechas de train estén entre 2021-01 y 2023-12 y test en 2024
serie_train = serie_train[(serie_train.index.year >= 2021) & (serie_train.index.year <= 2023)]
serie_test = serie_test[(serie_test.index.year == 2024)]
# Comprobaciones rápidas
print("=== Combinación objetivo ===")
print(f"ID_BUILDING={ID_BUILDING_OBJETIVO} | FM_COST_TYPE='{FM_COST_TYPE_OBJETIVO}'")
print(f"Meses en TRAIN: {len(serie_train)} | Rango: {serie_train.index.min().date()} -> {serie_train.index.max().date()}")
print(f"Meses en TEST : {len(serie_test)} | Rango: {serie_test.index.min().date() if len(serie_test)>0 else None} -> {serie_test.index.max().date() if len(serie_test)>0 else None}")
if len(serie_train) < 18:
raise ValueError("Tenemos muy pocos meses en entrenamiento para ajustar una estacionalidad anual. Cambiemos la combinación o usemos un modelo más simple.")
# Ajustamos Holt-Winters con estacionalidad mensual (12)
# Elegimos aditiva como punto de partida; si vemos multiplicatividad, podremos cambiar a 'multiplicative'.
model = ExponentialSmoothing(
serie_train,
trend="add",
seasonal="add",
seasonal_periods=12,
initialization_method="estimated" # dejamos que estime los estados iniciales
)
fit = model.fit(optimized=True)
# Predecimos exactamente los meses del test (2024)
steps = len(serie_test)
if steps == 0:
raise ValueError("No hay meses en el conjunto de test (2024) para esta combinación. Prueba con otra combinación o verifica los datos.")
pred_test = fit.forecast(steps=steps)
pred_test.index = serie_test.index # alineamos índices por claridad
# Métricas: RMSE y MAPE (seguro)
rmse = np.sqrt(mean_squared_error(serie_test.values, pred_test.values))
mape, excluidos = mape_safe(serie_test.values, pred_test.values)
print("\n=== Métricas baseline Holt-Winters (2024) ===")
print(f"RMSE: {rmse:,.2f}")
if np.isnan(mape):
print("MAPE: no calculable (todas las observaciones reales son 0).")
else:
print(f"MAPE: {mape:,.2f}% (se excluyeron {excluidos} meses con real=0 del cómputo)")
# Mostramos una tabla corta comparando real vs predicho para inspección
df_eval = pd.DataFrame({
"real_2024": serie_test,
"pred_hw": pred_test
})
print("\n=== Muestra real vs predicho (primeros 6 meses) ===")
print(df_eval.head(6))
# Guardamos resultados
# ruta_salida = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/"
# df_eval.to_csv(ruta_salida + f"eval_hw_{ID_BUILDING_OBJETIVO}_{FM_COST_TYPE_OBJETIVO.replace(' ', '_')}_2024.csv", sep=";", index=True)
=== Combinación objetivo === ID_BUILDING=1001131 | FM_COST_TYPE='Mtto. Contratos' Meses en TRAIN: 35 | Rango: 2021-01-01 -> 2023-11-01 Meses en TEST : 9 | Rango: 2024-01-01 -> 2024-09-01 === Métricas baseline Holt-Winters (2024) === RMSE: 139.50 MAPE: 92.83% (se excluyeron 6 meses con real=0 del cómputo) === Muestra real vs predicho (primeros 6 meses) === real_2024 pred_hw 2024-01-01 235.0 11.197043 2024-02-01 0.0 149.929063 2024-03-01 0.0 14.928970 2024-04-01 0.0 14.928911 2024-05-01 135.0 14.928805 2024-06-01 0.0 149.928728
Parece que hay mucha intermitencia (es decir, muchos meses con 0), lo que dispara el MAPE y hace que Holt-Winters “fuerce” una estacionalidad que no existe todos los meses (picos hasta los 150 aprox.).
Esto nos dice que antes de continuar, debemos estudiar bien nuestro modelo de datos y el comportamiento de las series. Para ello debemos crear un nuevo dataframe que clasifique por las parejas ID_BUILDING
y FM_COST_TYPE
, agrupando costes (cost_float_mod
) por mes (MONTH
) y año (YEAR
) para luego diagnosticar a través de KPIs y finalmente identificar el modelo predictivo de serie temporal que mejor se le adapte a la pareja.
En una sección específica, analizaremos los modelos o algoritmos disponibles para asignar uno a cada tipo de comportamiento.
Generamos un nuevo dataframe df_train_fd1_v5_ITE1
que contenga las series a predecir según la pareja ID_BUILDING
- FM_COST_TYPE
.¶
Queremos un panel mensual por (ID_BUILDING
, FM_COST_TYPE
) con la suma agregada mensual de cost_float_mod
en el periodo de entrenamiento 2021-2023. Esto nos definirá el conjunto de entrenamiento o train de las series a predecir.
Respetamos el tiempo: no usamos información de 2024 para construir el dataset de entrenamiento (evitamos que tengamos fuga de información).
Meses faltantes entre el primer y último mes existente de cada pareja los rellenamos con 0 para preservar la estructura mensual (necesario para los modelos basado en series temporales y para calcular métricas consistentes).
Adjuntamos, por comodidad, columnas de contexto dominantes (p. ej., COUNTRY_DEF
, TIPO_USO…
) calculadas solo con datos de entrenamiento (para no mirar el futuro).
Vamos a realizar esta sequencia:
- Filtramos el conjunto de entrenamiento (2021-2023)
Partimos de
df_fd1_v5_ITE1
.Creamos una variable de fecha mensual (
FECHA
) con el día 1 del mes.Filtramos explícitamente por
YEAR
entre 2021 y 2023 para asegurarnos.
- Agregamos a nivel mensual por pareja
Agrupamos por
ID_BUILDING
,FM_COST_TYPE
,FECHA
y sumamos cost_float_mod.Esto nos da la serie mensual agregada por pareja, pero con huecos de meses donde no hubo costes.
- Rellenamos los meses faltantes por pareja
Para cada pareja, reindexamos la serie entre su primer y último mes observado dentro de 2021-2023, a frecuencia mensual, y rellenamos con 0.
Así evitamos “inventarnos” meses antes del primer registro o después del último.
- Añadimos las variables de contexto necesario por pareja.
Para cada pareja, calculamos el modo (valor más frecuente) de variables de contexto (
COUNTRY_DEF
,ID_REGION_GRUPO
,TIPO_USO
,SUPPLIER_TYPE_MOD_2
,FM_RESPONSIBLE_MOD
) solo usando 2021-2023.Esto nos da una “foto” estable de cada pareja o serie a predecir, útil para el modelado posterior.
- Ensamblamos
df_train_fd1_v5_ITE1
Unimos la agregación mensual completa con el contexto deseado.
Añadimos columnas
YEAR
yMONTH
derivadas deFECHA
.Dejamos nombres claros y orden de columnas práctico.
- Realizamos una comprobacion rápida
- Forma del dataframe, rango temporal y muestra de filas para validar que todo está OK y sin duplicados.
# ===============================
# PASO 1. Filtramos entrenamiento
# ===============================
# Partimos de df_fd1_v5_ITE1 ya cargado (2021–2023).
# Creamos FECHA como el día 1 de cada mes y nos aseguramos del rango.
df_train_base = df_fd1_v5_ITE1.copy()
df_train_base["FECHA"] = pd.to_datetime(dict(year=df_train_base["YEAR"],
month=df_train_base["MONTH"],
day=1), errors="coerce")
df_train_base = df_train_base[(df_train_base["YEAR"] >= 2021) & (df_train_base["YEAR"] <= 2023)]
# Eliminamos cualquier posible fila con FECHA nula (por seguridad)
df_train_base = df_train_base[df_train_base["FECHA"].notna()]
# =========================================
# PASO 2. Agregamos a nivel mensual/pareja
# =========================================
# Sumamos el coste mensual por (ID_BUILDING, FM_COST_TYPE, FECHA)
monthly_train = (df_train_base
.groupby(["ID_BUILDING", "FM_COST_TYPE", "FECHA"], as_index=False)["cost_float_mod"]
.sum())
# ===================================================
# PASO 3. Rellenamos meses faltantes por cada pareja
# ===================================================
# Nos definimos una función para rellenar los meses faltantes donde la entrada
# g es un DataFrame para una pareja con columnas ['ID_BUILDING','FM_COST_TYPE'
# ,'FECHA','cost_float_mod']
def reindex_group(g):
g = g.sort_values("FECHA")
fecha_min = g["FECHA"].min()
fecha_max = g["FECHA"].max()
# Creamos el índice mensual continuo entre min y max
idx = pd.period_range(fecha_min, fecha_max, freq="M").to_timestamp()
# Reindexamos
g2 = g.set_index("FECHA").reindex(idx)
g2.index.name = "FECHA"
# Rellenamos coste a 0 donde no haya registro
g2["cost_float_mod"] = g2["cost_float_mod"].fillna(0.0)
# Recuperamos las claves
g2["ID_BUILDING"] = g["ID_BUILDING"].iloc[0]
g2["FM_COST_TYPE"] = g["FM_COST_TYPE"].iloc[0]
return g2.reset_index()
df_train_fd1_v5_ITE1 = (monthly_train
.groupby(["ID_BUILDING","FM_COST_TYPE"], group_keys=False)
.apply(reindex_group)
.reset_index(drop=True))
# ===========================================
# PASO 4. Contexto por cada pareja
# ===========================================
# Nos definimos una función para añadir contexto de cada pareja
# ID_BUILDING-FM_COST_TYPE
def mode_or_nan(s: pd.Series):
m = s.mode()
return m.iloc[0] if len(m) else np.nan
ctx_cols = ["COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"]
ctx_train = (df_train_base
.groupby(["ID_BUILDING","FM_COST_TYPE"])
.agg({c: mode_or_nan for c in ctx_cols})
.reset_index())
# Unimos contexto al panel mensual
df_train_fd1_v5_ITE1 = df_train_fd1_v5_ITE1.merge(ctx_train,
on=["ID_BUILDING","FM_COST_TYPE"],
how="left")
# =============================
# PASO 5. Orden y columnas base
# =============================
# Añadimos YEAR y MONTH a partir de FECHA
df_train_fd1_v5_ITE1["YEAR"] = df_train_fd1_v5_ITE1["FECHA"].dt.year
df_train_fd1_v5_ITE1["MONTH"] = df_train_fd1_v5_ITE1["FECHA"].dt.month
# Ordenamos las columnas para modelizar series temporales
cols_orden = [
"ID_BUILDING","FM_COST_TYPE","FECHA","YEAR","MONTH",
"cost_float_mod", # coste mensual agregado
"COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"
]
df_train_fd1_v5_ITE1 = df_train_fd1_v5_ITE1[cols_orden]
# ==========================
# PASO 6. Comprobaciones
# ==========================
print("Forma df_train_fd1_v5_ITE1:", df_train_fd1_v5_ITE1.shape)
print("Rango fechas:", df_train_fd1_v5_ITE1["FECHA"].min().date(), "->", df_train_fd1_v5_ITE1["FECHA"].max().date())
# Verificamos duplicados por clave que no deberían haber
dups = df_train_fd1_v5_ITE1.duplicated(subset=["ID_BUILDING","FM_COST_TYPE","FECHA"]).sum()
print("Duplicados (ID_BUILDING, FM_COST_TYPE, FECHA):", dups)
print("\nMuestra:")
print(df_train_fd1_v5_ITE1.head(10))
# Guardamos en Drive
# ruta_salida = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/"
# df_train_fd1_v5_ITE1.to_csv(ruta_salida + "df_train_fd1_v5_ITE1.csv", sep=";", index=False)
Forma df_train_fd1_v5_ITE1: (65466, 11) Rango fechas: 2021-01-01 -> 2023-12-01 Duplicados (ID_BUILDING, FM_COST_TYPE, FECHA): 0 Muestra: ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Eficiencia Energética 2021-12-01 2021 12 0.00 1 2 Licencias 2021-01-01 2021 1 1145.46 2 2 Licencias 2021-02-01 2021 2 55.95 3 2 Licencias 2021-03-01 2021 3 0.00 4 2 Licencias 2021-04-01 2021 4 0.00 5 2 Licencias 2021-05-01 2021 5 305.95 6 2 Licencias 2021-06-01 2021 6 75.00 7 2 Licencias 2021-07-01 2021 7 850.95 8 2 Licencias 2021-08-01 2021 8 0.00 9 2 Licencias 2021-09-01 2021 9 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas INTERNO 1 España 2 Oficinas INTERNO 2 España 2 Oficinas INTERNO 3 España 2 Oficinas INTERNO 4 España 2 Oficinas INTERNO 5 España 2 Oficinas INTERNO 6 España 2 Oficinas INTERNO 7 España 2 Oficinas INTERNO 8 España 2 Oficinas INTERNO 9 España 2 Oficinas INTERNO FM_RESPONSIBLE_MOD 0 Eficiencia Energética 1 Licencias 2 Licencias 3 Licencias 4 Licencias 5 Licencias 6 Licencias 7 Licencias 8 Licencias 9 Licencias
Una vez tenemos el conjunto train df_train_fd1_v5_ITE1
para el entrenamiento completo, vamos a realizar un analisis identificando los KPI del conjunto que nos ayude a identificar su comportamiento y, a partir de esto, asignar un modelo predictivo de serie temporal más adecuado.
Definimos funciones para cada kpi de interes que nos permita decidir sobre el método a utilizar para cada tipo de serie, dada una pareja ID_BUILDING
- FM_COST_TYPE
¶
Función safe_autocorr
¶
Definiremos una primera función safe_autocorr
que nos permitirá aplicarla y con ello poder tomar decisiones según la persistencia y estacionalidad de dos series en la que la segunda es la primera desplazada en el tiempo.
Valores de Entrada:
x
: una serie (pandas Series) de valores ordenados en el tiempo.lag
: el número de periodos hacia atrás con el que queremos correlacionar (p. ej., 1 para meses o 12 para anualidad).Control de la longitud de las series (len(x) <= lag):
Si no hay suficientes datos para comparar x[t] con x[t-lag] (por ejemplo, lag=12 pero solo hay 10 meses), no se puede calcular autocorrelación: devuelve NaN. Así nos evitamos errores.
Cálculamos la autocorrelación:
Usaremos
pandas.Series(x).autocorr(lag=lag)
, que equivale al coeficiente de correlación de Pearson entre las dos series desplazadas:ρk=corr(Xt,Xt-k)
(ignorando los pares con NaN). Luego lo convertiremos a
float
por consistencia.
Consideramos un manejo de excepciones:
- Si ocurre algo inesperado (tipos raros, todo NaN, etc.), capturaremos la excepción y la función devolverá NaN en vez de romper el flujo.
Interpretación:
Esta función es util para evaluar la persistencia mes a mes con lag = 1 o la estacionalidad con lag = 12.
acf1 (lag=1) indica persistencia mes a mes (si los valores altos siguen a valores altos).
acf12 (lag=12), en datos mensuales, sugiere estacionalidad anual si es alto.
Ejemplo rápido:
con [1,2,3,4] y lag=1, correlaciona [2,3,4] con [1,2,3] → autocorrelación alta (≈1).
Con menos de 2 puntos útiles o varianza cero, devolverá NaN o un valor no definido de forma segura.
Fuentes:
https://otexts.com/fpp3/acf.html
https://www.itl.nist.gov/div898/handbook/eda/section3/eda35c.htm
https://www.itl.nist.gov/div898/handbook/eda/section3/autocopl.htm
def safe_autocorr(x: pd.Series, lag: int) -> float:
"""
Calcula la autocorrelación de una serie en un retardo (lag) concreto
Nos sirve para tomar decisiones en función del valor que devuelve y el lag de entrada.
Devuelve un float con la autocorrelación de Pearson de la serie en el retardo lag (valor entre -1 y 1).
Si no hay datos suficientes (len(x) <= lag) o algo falla durante el cálculo, devuelve np.nan
"""
# Calculamos la autocorrelación a un retardo (lag) dado.
# Si la serie es demasiado corta para ese lag, devolvemos NaN para evitar errores.
if len(x) <= lag:
return np.nan
try:
# Usamos el método autocorr de pandas; convertimos a float por consistencia.
return float(pd.Series(x).autocorr(lag=lag))
except Exception:
# Si algo falla (valores raros, etc.), devolvemos NaN de forma segura.
return np.nan
Función seasonal_strength_stl
¶
Definimos una segunda función seasonal_strength_stl
que estima cuánta “fuerza” tiene la estacionalidad en una serie mensual (o con el periodo que indiques) usando una descomposición STL y devuelve un número entre 0 y 1.
Qué entradas tiene la función
x: pd.Series
: Serie numérica (float) ordenada temporalmente.
Idealmente con frecuencia regular (mensual si period=12).
Sin NaN (o ya imputados), porque STL no los tolera bien.
Longitud mínima recomendada: al menos 2 * period (si no, la función devuelve np.nan).
Es útil tener el índice de x
como DatetimeIndex
y la serie reindexada a todos los meses del rango (sin huecos).
period: int = 12
: Longitud del ciclo estacional que queremos detectar.
- Debe ser un entero ≥ 2.
Qué calculamos:
STL descompone la serie como:
- xt = tendenciat + estacionalt + residuot
La fuerza estacional se define como:
- Fs=max(0,(1-(Var(residuo)/Var(residuo+estacional))))
Interpretación:
Si la estacionalidad explica mucha varianza (el residuo es pequeño), Fs tiende a 1
Si casi no hay estacionalidad (todo es ruido), Fs se situa entorno a 0
Lo que hacemos en la función es:
- Comprobamos su longitud
if len(x) < 2*period: return np.nan
(STL necesita al menos ~2 ciclos completos para estimar bien la estacionalidad (p. ej., 24 meses si period=12).)
- Realizamos la descomposición STL robusta
res = STL(x, period=period, robust=True).fit()
(robust=True reduce la influencia de outliers.)
Extraemos: res.seasonal (componente estacional) y res.resid (residuo).
- Procedemos con el cálculo de la métrica
denom = Var(resid + seasonal) es la varianza de “lo no tendencial”.
(Si denom <= 0, la serie (sin tendencia) es prácticamente constante ⇒ devolvemos 0.0.)
fs = 1 - Var(resid) / Var(resid + seasonal) y lo acotamos a [0,1].
- Consideramos un manejo de los errores:
Cualquier fallo (datos raros, etc.) devuelve np.nan sin romper el flujo.
Cómo debemos interpretar el resultado:
Fs >= 0.7: estacionalidad fuerte y estable.
0.3 < Fs < 0.7: estacionalidad moderada.
Fs <= 0.3: poca o nula estacionalidad.
NaN: datos insuficientes o problema en la descomposición.
Notas prácticas sobre la función y su interpretación:
- Definición del
period
:
period
= 12 para mensual (estacionalidad anual),period
= 7 para diaria con patrón semanal,period
= 24 para horaria con patrón diario, etc.
- En series muy intermitentes (muchos ceros), STL suele dar baja fuerza estacional aunque existan picos; a veces conviene transformar (p. ej., log1p) o usar métodos específicos de demanda intermitente.
Permite decidir segun su valor:
Calculamos el seasonal_strength_stl
para cada pareja ID_BUILDING
- FM_COST_TYPE
para luego ordenarlo de mayor a menor. El resultado nos da una priorización de parejas con estacionalidad clara (ver https://feasts.tidyverts.org/
).
Con Fs alto, vamos a probar con Holt-Winters/Prophet; con Fs bajo, consideraremos modelos sin estacionalidad o intermitentes.
Fuentes:
https://robjhyndman.com/hyndsight/tscharacteristics/
https://otexts.com/fpp2/seasonal-strength.html
https://otexts.com/fpp3/stlfeatures.html
https://journal.r-project.org/archive/2021/RJ-2021-050/RJ-2021-050.pdf
def seasonal_strength_stl(x: pd.Series, period: int = 12) -> float:
"""
Estima cuánta “fuerza” tiene la
estacionalidad en una serie mensual (o con el periodo que indiquemos) usando
una descomposición STL.
Devuelve un número entre 0 y 1.
"""
# Estimamos la "fuerza estacional" según Hyndman:
# Fs = max(0, 1 - Var(remainder) / Var(remainder + seasonal))
# Intuición: si la estacionalidad explica mucha varianza, Fs se acerca a 1.
# Requisito: al menos ~2 periodos para que STL sea mínimamente fiable.
if len(x) < 2*period:
return np.nan
try:
# Descomposición STL robusta para minimizar el impacto de outliers
res = STL(x, period=period, robust=True).fit()
rem = res.resid # componente residual
seas = res.seasonal # componente estacional
# Var(remainder + seasonal) = varianza de todo lo que no es tendencia
denom = np.var(rem + seas)
if denom <= 0:
# Si el denominador es 0 (serie constante), forzamos fuerza 0
return 0.0
fs = 1 - (np.var(rem) / denom)
# Acotamos en [0,1] por seguridad numérica
return float(np.clip(fs, 0, 1))
except Exception:
# Si STL falla por cualquier motivo, devolvemos NaN
return np.nan
Función robust_outlier_rate
¶
Aunque ya habíamos decidido no eliminar los outliers del dataset, vamos a definir una tercera función robust_outlier_rate
que mida el porcentaje de meses que son “atípicos” (outliers) usando un z-score robusto basado en la MAD (Median Absolute Deviation).
Ventajas de usar Z-score robusto:
La ventaja de usar el z-score robusto frente al z-score clásico (media/desv.est.) es precisamente su mayor robustez, como indica su nombre, porque evita romperse cuando hay picos enormes al trabajar con la mediana/MAD y no solo con la media y la desviación típica.
Además, es más sencilla y rápida de calcular.
Valores de entrada:
x: pd.Series
: la serie mensual de costesthr: float = 3.5
: un umbral de límite outlier.
Caso by-pass: si x está vacía → devuelve NaN (no hay información).
Calculamos Mediana y MAD:
med = median(x)
mad = median(|x - med|)
Interpretación
La MAD es robusta: apenas se “mueve” aunque haya picos muy grandes.
Serie casi constante: si mad == 0, la función retorna 0.0 (no signal de outliers).
Esto evita dividir por cero.
NOTA: en series muy intermitentes (muchos ceros), puede ocurrir mad==0 y esto “oculta” los picos. Habrá que analizar el % de ceros.
Definimos el z-score robusto:
zrob = 0.6745 ⋅ (x-med) / MAD
El valor de 0.6745 es la constante que hace que, si la serie fuese normal, este z-score sea comparable a un z-score clásico (porque para Normal(0,σ), MAD ≈ 0.6745·σ).
Criterio de outlier:
Un mes es outlier si |zrob| > thr
El valor 3.5 es un estándar conservador (más estricto que 3.0).
Valor de salida:
Es la proporción de meses marcados como outliers, entre 0 y 1.
Ej.: 0.06 ⇒ 6% de meses atípicos.
Cómo interpretar el resultado:
Entre 0.00 y 0.02: casi sin atípicos; distribución estable.
Entre 0.02 y 0.10: algunos picos; revisar si son abonos o eventos reales.
Superior a 0.10: muchos atípicos; conviene transformar (p. ej., log1p) o winsorizar/capar outliers antes de modelar.
Limitaciones y advertencias (importante para nuestro caso):
- Series con muchos ceros:
Cuando más de la mitad de los valores son cero, la mediana es 0 y la MAD puede ser 0 → la función devuelve 0.0 y no detecta picos.
Alternativas a considerar cuando veamos mad = 0:
Calcular la MAD solo sobre meses positivos (>0).
Usar un criterio IQR (p. ej., fuera de [Q1–1.5·IQR, Q3+1.5·IQR]) sobre positivos.
Añadir una ε pequeña: mad = max(mad, ε) para no colapsar (y documentarlo).
- Valores negativos (abonos):
- El método los puede marcar como outliers si son grandes en magnitud. Si los abonos son normales en tu negocio, quizá quieras tratarlos aparte (otra etiqueta) y no contarlos como outliers “problemáticos”.
Criterios para modificar el umbral thr
:
thr
= 3.5 es conservador (marca menos outliers).Si queremos sensibilidad (detectar más picos), bajamos
thr
a 3.0.Si queremos ser más restrictivos, subimos
thr
a 4.0.
Como conclusión:
La función devuelve un porcentaje de meses atípicos robusto a picos.
Cuando veamos mad = 0 puede ser por que hay muchas filas a 0, por lo que no podemos concluir que no haya picos: deberemos aplicar una de las alternativas de arriba para no perder señal.
Qué nos permite decidir:
Usa este indicador para decidir preprocesado: si sale alto, transformación (log1p), capping o modelos específicos (y, en intermitentes, Croston/SBA/TSB).
Fuentes:
https://hwbdocs.env.nm.gov/Los%20Alamos%20National%20Labs/TA%2054/11587.pdf
https://www.itl.nist.gov/div898/handbook/eda/section3/eda35h.htm
def robust_outlier_rate(x: pd.Series, thr: float = 3.5) -> float:
"""
Identificar % de outliers de cada pareja
ID_BUILDING - FM_COST_TYPE para cada mes y año.
Calculamos el % de meses que consideramos outliers usando un z-score robusto
basado en MAD.
Devuelve un valor binario en función si es outlier o no.
"""
# Calculamos:
# La Mediana y MAD (desviación absoluta mediana).
# El z_robusto = 0.6745 * (x - mediana) / MAD (0.6745 normaliza a equivalente-z)
if len(x) == 0:
return np.nan
med = np.median(x)
mad = np.median(np.abs(x - med))
if mad == 0:
# Si MAD=0 (serie casi constante), no hay outliers robustos
return 0.0
z = 0.6745 * (x - med) / mad
return float((np.abs(z) > thr).mean())
# Marcamos outlier según la salida|z_robusto| > thr (por defecto 3.5, criterio habitual)
Función trend_slope
¶
Vamos a definir también una cuarta función que complementa el resto de kpis, es la función trend_slope
.
Esta función estima la pendiente de tendencia lineal de la serie mensual x
de costes. Es decir, cuánto sube o baja por mes, en promedio, cuando ajustamos una recta a los datos.
Formalmente, ajusta según la ecuación de una recta:
xt ≈ b0 + b1⋅t con t = 0, 1, …, n-1
y devuelve b1 que es la pendiente de la recta.
Con np.polyfit(t, x, 1)
, el primer valor es b1 y el segundo es b0
Valores de entrada:
x: pd.Series
- La serie de costes mensuales para la parejaID_BUILDING
YFM_COST_TYPE
.
Definimos el tamaño mínimo:
- Si n < 3, devuelve 0.0. Con menos de 3 meses sin coste asociado a la pareja, la definición de una recta para marcar tendencia es demasiado inestable y es mejor no facilitar el dato.
Fijamos un indice temporal uniforme (en meses)
Definimos t = [0, 1, …, n-1].
Como hemos reindexado a meses continuos, cada salto de t equivale a 1 mes.
Realizamos el ajuste lineal
np.polyfit(t, x.values, 1) calcula mínimos cuadrados ordinarios.
Recoge b1 (pendiente) y b0 (intercepto), y devuelve b1 como float.
Consideramos un manejo de los errores:
- Si algo falla (datos no numéricos, etc.), devuelve 0.0 como salida segura.
Qué devuelve y cómo debemos interpretar
Devuelve un valor tipo float.
Unidades: “unidades de coste por mes”.
Ej.: b1 = 50 ⇒ de media, el coste aumenta 50 al mes.
b1 = -30 ⇒ de media, cae 30 al mes.
Escala temporal: mensual. Si queremos anualizar, multiplicamos por 12.
Interpretación
Es un KPI de tendencia simple para cada (ID_BUILDING
,FM_COST_TYPE
):
Cuando b1 > 0: deriva creciente (podría requerir atención presupuestaria).
b1 ≈ 0: sin tendencia clara (quizá estacional o errática).
b1 < 0: deriva decreciente (ahorros, fin de proyectos, etc.).
Matices y cautelas a tener en cuenta:
- Sensibilidad a valores atípicos (outliers): al minimizar la suma de los errores al cuadrado, los picos muy grandes van a sesgar b1.
Alternativas cuando esto nos ocurra:
Tendencia de STL y calcular la pendiente sobre ese componente (más robusto a estacionalidad).
Regresiones robustas (Theil–Sen, Huber, RANSAC) o L1 (least absolute deviations).
Transformaciones (p. ej., log1p) o winsorización para reducir el impacto de outliers antes de estimar la pendiente.
- Estacionalidad fuerte: una serie con estacionalidad marcada puede dar una pendiente “engañosa” si el intervalo cubre ciclos incompletos.
- Para aislar tendencia, podemos medir b1 sobre la tendencia de STL.
- Intermitencia (muchos ceros): si domina la ausencia de gasto, la pendiente puede ser cercana a 0 aunque existan picos puntuales.
- La deberemos combínar con %ceros, ADI, etc.).
Comparabilidad entre series: para comparar edificios con escalas distintas, podemos:
- crear una versión normalizada, por ejemplo: b1/x‾ (cambio relativo por mes) o bien
- calcular la pendiente sobre log1p(x) para aproximar un % mensual.
Fuentes:
https://www.sefh.es/fh/2002/n2/2.pdf
def trend_slope(x: pd.Series) -> float:
"""
Estimamos la pendiente de tendencia mediante
una regresión lineal simple sobre el índice temporal (0..n-1).
Si n<3 devolvemos 0 por estabilidad.
"""
n = len(x)
if n < 3:
return 0.0
t = np.arange(n)
try:
# np.polyfit devuelve [pendiente, intercepto]; nos quedamos con la pendiente
b1, b0 = np.polyfit(t, x.values, 1)
return float(b1)
except Exception:
# Ante cualquier problema numérico, devolvemos 0
return 0.0
Función adi_metrics
¶
La función adi_metrics
mide la intermitencia de la serie mensual x
(gastos) con el indicador ADI (Average Demand Interval) y devuelve un pequeño resumen.
El ADI mide, en media, cuántos meses pasan entre dos meses con gasto positivo.
Regla práctica:
si ADI > 1,32 la serie se considera intermitente;
si además la variabilidad de los meses positivos es alta (CV2 > 0,49), se le llama lumpy.
En series mensuales reindexadas como es nuestro caso, un ADI de 3 significa que “de media hay un mes con gasto > 0 cada 3 meses”
La función nos devuelve dos ADI
ADI_n_over_d
(n/d): es la métrica estándar (clásica) y súper rápida de interpretar/comparar entre series.
ADI_mean_gap
: es un complemento que no penaliza los ceros antes del primer positivo ni después del último; a veces resulta más representativo si el tramo empieza/termina en rachas largas de ceros.
En series largas y “bien pobladas”, ambos valores suelen ser similares. Si difieren mucho, normalmente es por ceros en los bordes del tramo.
Casos límite y lectura
dcount
= 0 → `ADI_n_over_d' = inf, ADI_mean_gap = inf: sin demanda (sin costes o todos los costes negativos); intermitencia máxima.
dcount
= 1 → ADI_n_over_d
= n, ADI_mean_gap
= n: solo un evento en todo el periodo.
Muchos ceros → ambos ADI crecen (señal de intermitencia).
Meses negativos (abonos): no cuentan como demanda (solo se consideran >0). Si los abonos fueran habituales y quisiéramos que contasen como “evento”, deberíamos ajustar el criterio (> 0 → ≠ 0 o > umbral, según los ajustes que haga el equipo FM).
Cómo debemos usarlo en decisiones:
ADI > 1,32 → se trata la serie como intermitente: podemos probar:
- Croston/SBA/TSB o
- modelo en dos partes (probabilidad × tamaño).
ADI ≤ 1,32 → la ocurrencia es frecuente; entonces evaluar según sea CV2 en función de la variabilidad de importes.
Fuentes:
https://www.ias.ac.in/public/Volumes/sadh/045/00/0051.pdf
https://openforecast.org/2024/07/16/intermittent-demand-classifications-is-that-what-you-need/
# Función adi_metrics en la que se
def adi_metrics(x: pd.Series) -> dict:
"""
Mide cuántos meses pasan entre dos meses con gasto positivo.
Métricas ADI (intermitencia):
- ADI_n_over_d = n / (#meses con >0) [clásico y simple]
- ADI_mean_gap = media de separaciones entre meses positivos consecutivos (en nº de meses)
Devuelve las 2 métricas ADI y el n. meses con demanda.
"""
n = len(x) # nº total de meses en el tramo (p.ej., 36 si 2021–2023 completo)
pos_idx = np.where(x.values > 0)[0] # posiciones (meses) con gasto > 0
dcount = len(pos_idx) # nº de meses con demanda (positivos)
# Usamos la definición de ADI: “periodos totales” / “nº de demandas”.
# Si no hay ningún mes positivo (dcount=0) nos devuelve infinito
# (intermitencia extrema).
# Si hay 12 meses positivos en 36 meses entonces ADI = 36/12 = 3.0.
adi_n_over_d = (n / dcount) if dcount > 0 else np.inf
# Calculamos la media de las separaciones (en meses) entre posiciones
# de demanda (>0). Ignoramos los “ceros iniciales” antes del primer positivo y
# los “ceros finales” después del último positivo
# (a diferencia de n/dcount que implícitamente los incluye).
# Si solo hay 1 mes positivo, devuelve n (no hay un “gap” entre positivos).
# Si no hay ninguno, devuelve infinito.
if dcount >= 2:
gaps = np.diff(pos_idx) # separaciones en meses
adi_mean_gap = float(np.mean(gaps))
elif dcount == 1:
adi_mean_gap = float(n) # una sola demanda en todo el tramo
else:
adi_mean_gap = np.inf
return {"ADI_n_over_d": float(adi_n_over_d), "ADI_mean_gap": adi_mean_gap, "demand_months": int(dcount)}
Función cv2_positivos
¶
La función cv2_positivos
mide la variabilidad relativa de los importes solo en los meses con gasto > 0. Devuelve el coeficiente de variación al cuadrado (CV2) de esos valores positivos.
Qué hace la función:
Primero filtramos la serie x
y se queda con xp = x [x > 0].
Razonamiento: los ceros no informan sobre el tamaño del gasto cuando ocurre; mezclarlos subestimaría la variabilidad de los importes.
Entonces si hay ≤ 1 mes positivo entonces no hay base para estimar variabilidad y por consiguiente devolvemos NaN.
Después calculamos la media m = mean(xp) y la desviación típica s = std(xp, ddof=1).
Devolvemos CV^2 = (s/m)^2 como float.
(Si m == 0, devuelve NaN para evitar división por cero.)
Por qué nos es útil:
Con ADI midiendo la intermitencia, CV2 te dice cuánto varían los importes cuando hay gasto.
Es la segunda dimensión de la clasificación Syntetos-Boylan-Croston:
CV2 ≤ 0.49 implica baja variabilidad de importes (p. ej., smooth si ADI también bajo).
CV2 > 0.49 implica alta variabilidad (p. ej., erratic o lumpy según ADI).
Ayuda a decidir preprocesado/modelo:
CV2 alto → probar transformaciones (p. ej., log1p), winsorización o modelos robustos.
CV2 bajo → modelos simples (SES/Holt) suelen ir bien si la intermitencia también es baja.
Interpretar (reglas rápidas):
Bajo (≤ 0.49): los importes positivos son “parecidos” entre sí.
Alto (> 0.49): hay picos/columpios grandes entre meses con gasto.
NaN: muy pocos meses con gasto (≤ 1) o media positiva ≈ 0 → no interpretable.
Matices importantes a considerar:
Solo > 0: los abonos (valores negativos) no cuentan en nuestra función. Si en el equipo FM fueran frecuentes y relevantes, deberíamos adaptar el filtro (p. ej., x != 0 o x > umbral).
Tamaño de muestra: con 2-3 valores positivos el CV2 es inestable; debemos usarlo con cautela.
Escala-invariante: es una medida adimensional, comparable entre edificios/tipos de coste.
Fuentes: Las mismas que ADI.
# Función cv2_positivos
def cv2_positivos(x: pd.Series) -> float:
"""
Mide la variabilidad relativa de los importes solo en los meses con gasto > 0.
Devuelve el coeficiente de variación al cuadrado (CV2) de esos valores positivos.
"""
# CV² sobre meses con >0 (si hay suficientes y media>0)
xp = x[x > 0]
if len(xp) <= 1:
return np.nan
m = float(xp.mean())
if m == 0:
return np.nan
s = float(xp.std(ddof=1))
return float((s / m) ** 2)
Función max_zero_run
¶
La función max_zero_run
mide, para una serie mensual x
, la racha (longitud) más larga de meses consecutivos con valor 0. Es un indicador muy claro de “sequías” de gasto.
Valores de entrada:
x: pd.Series
-> la serie que se desea evaluar
Binarizamos la serie:
Creamos un array con un 1 si el mes tiene 0 y un 0 si tiene un coste > 0 (en este caso los valores negativos también cuentan como no-cero y cortan la racha).
Asignamos el máximo:
Recorremos la secuencia sumando la racha actual de unos (n. ceros consecutivos) y guardamos el máximo encontrado.
Devolvemos entero:
Devolvemos ese máximo como un entero que indica el número máximo de meses de la serie sin costes.
Si la serie está vacía -> devuelve 0.
Si no hay ningún cero -> devuelve 0.
Interpretación:
Valor = 0 -> nunca hubo un mes con gasto exactamente 0.
Valor = 3 -> hubo al menos una racha de 3 meses seguidos sin gasto.
Valor >= 6 -> sequías largas; comportamiento muy discontinuo o posible obsolescencia/cierres.
Por qué es útil (y en qué se diferencia de otros KPIs)
- Complementa al % de ceros (sparsity) y al ADI:
%ceros dice cuántos meses son 0 en total, no si están seguidos.
ADI mide el promedio entre ocurrencias > 0;
max_zero_run
mira el peor caso (racha más larga).
- Dos series con el mismo % de ceros pueden comportarse distinto: una puede tener ceros dispersos (max run pequeño) y otra agrupados (max run grande). Esto nos cambiará el enfoque de modelado.
Cómo usarlo en decisiones
Valor >= 3 meses: ya hay intermitencia notable -> considerar Croston/SBA/TSB o un modelo en dos partes (probabilidad × importe).
Valor muy alto (p. ej., >= 6): revisar si hay cierres, cambios operativos o falta de registro (data quality); TSB encaja bien si también cae la probabilidad de ocurrencia con el tiempo.
Para validación temporal, evita folds compuestos solo por ceros: usa ventanas que incluyan alguna ocurrencia > 0.
Matices y casos límite
Valores NaN: en nuestro pipeline, tras el reindex se rellenan a 0, así que no deberían aparecer. Si aparecieran, conviene decidir si tratarlos como faltantes (ignorar) o como 0 explícitamente.
Valores negativos (abonos): rompen la racha (no son 0). Si quisieramos que “no gasto neto” cuente también como racha, podríamos usar una variante para coste ≤ 0.
Casi-ceros: si hay meses con importes residuales (p. ej., 0.01), podrías usar una variante con umbral E: “mes en cero” si |x| < E.
Complejidad y robustez
Recorre la serie una sola vez → O(n).
No necesita parámetros (solo define “cero” literal); extensiones con umbral E son triviales.
En resumen, max_zero_run
cuantifica la peor sequía de gasto: es clave para distinguir series con el mismo % de ceros pero patrones operativos muy distintos, y para decidir si aplicar modelos de demanda intermitente o revisar el dato/operativa.
Fuentes:
https://blog.arkieva.com/managing-intermittent-demand/
https://thesis.eur.nl/pub/66680/-304
https://smartcorp.com/wp-content/uploads/2015/07/IJF_Bootstrap_paper_Smart_Software.pdf
def max_zero_run(x: pd.Series) -> int:
"""
Mide la racha (longitud) más larga de meses consecutivos con valor 0.
Es un indicador muy claro de “sequías” de gasto.
"""
# Longitud máxima de racha consecutiva de ceros
if x is None or len(x) == 0:
return 0
arr = (x.values == 0).astype(int)
max_run = run = 0
for v in arr:
run = run + 1 if v == 1 else 0
if run > max_run:
max_run = run
return int(max_run)
Función occ_prob_and_drift
¶
La función occ_prob_and_drift
resume, para una serie mensual x
(costes), con qué frecuencia “ocurre gasto” y si esa probabilidad está cambiando en el tiempo.
Calculamos
- El indicador de ocurrencia mensual que se construye con:
- yt = 1 {xt > 0} (1 si hay gasto ese mes; 0 si no).
- la probabilidad media de ocurrencia
- p=1/n·∑(yt) (de t=1 hasta n)
Que equivalen a la proporción de meses con gasto en el periodo de la serie, que en nuestro conjunto train va des de 2021 hasta 2023.
- La tendencia de la ocurrencia que se llama “drift”.
Ajustamos una recta minimizando la suma de los errores al cuadrado sobre el tiempo al indicador binario:
yt ≈ β0 + β1⋅t donde t=0,1,…,n-1
Devuelve un valor float:
El valor de la probabilidad media de la ocurrencia
p_occurrence
(que es un valor entre 0 y 1).El valor de
occurrence_slope
= \β1, que es el cambio promedio por mes en la probabilidad de que haya gasto.
Si occurrence_slope = 0.02, la probabilidad sube ~2 puntos porcentuales al mes.
Si -0.01, baja ~1 p.p. al mes (posible obsolescencia/apagado del gasto).
Si la serie tiene menos de 3 meses o hay problemas numéricos -> devuelve 0.0 como salida segura.
Interpretaciones
El indicador p(ocurre) separa el “¿hay gasto?” de “¿cuánto es cuando ocurre?”.
- Es clave para decidir si conviene un modelo en dos partes (hurdle) o métodos de demanda intermitente (TSB modela precisamente probabilidad × tamaño).
El Drift de ocurrencia (o pendiente de la recta):
Negativo -> síntomas de obsolescencia (cada vez menos meses con gasto). TSB suele encajar mejor que Croston/SBA.
Positivo -> el gasto aparece cada vez más a menudo (cambios operativos, nuevos contratos, etc.).
Cercano a 0 -> ocurrencia estable en el tiempo.
Ejemplo de interpretaciones de algunos resultados
p_occurrence
= 0.25 -> 1 de cada 4 meses hay gasto (intermitencia moderada).occurrence_slope
= -0.015 -> la probabilidad cae un 1.5 p.p./mes. En un año, ≈ -18 p.p. (multiplica por 12 para anualizar de forma aproximada).
Buenas prácticas y matices
Binarización (> 0): por defecto considera gasto “ocurre” si x > 0.
Cuando tenemos abonos frecuentes y quieres contabilizarlos como “evento”, puedes cambiar la regla (p. ej., x != 0 o x > E).
Ajustamos con una recta minimizando la suma de los errores al cuadrado sobre un serie binaria, ello implica que la pendiente es intuitiva y rápida, pero no encaja bien con un modelo probabilístico; si necesitaramos una inferencia rigurosa, deberíamos usar la regresión logística de yt contra t (o Mann-Kendall para testar tendencia monótona).
Estacionalidad de ocurrencia: si hay meses del año con más/menos probabilidad, la recta global puede “promediar” ese patrón. Para captarlo deberiamos:
añadir dummies de mes en la logística, o
calcular el drift sobre ventanas móviles (rolling) para detectar cambios de régimen.
Series muy cortas o con pocos positivos: la pendiente es inestable.
Deberíamos interpretarla junto con:
p_occurrence
(si es muy baja, cualquier cambio parece grande),ADI, max_zero_run, y %ceros.
Validación: evitamos folds de validación con todo ceros para evaluar la parte de importe; para la ocurrencia sí podemos evaluar con métricas de clasificación (AUC/PR, F1,...), separando tareas.
Uso en decisiones (resumen):
p baja + drift negativo + ADI alto -> fuerte intermitencia con caída: TSB o hurdle (clasificación de ocurrencia + regresión de importe).
p estable y drift ≈ 0 -> ocurrencia estacionaria: enfoca la modelización en el importe (ETS/Holt/Prophet si hay estacionalidad, o transformaciones si CV2 es alto).
p al alza + drift positivo -> considera cambios operativos y revisa si el baseline debe actualizarse con más frecuencia.
Con esto, occ_prob_and_drift
te da dos señales compactas para diagnosticar la frecuencia y la evolución de la ocurrencia de gasto—perfectas para decidir si vas por intermitente/hurdle o por modelos clásicos centrados en magnitud.
Fuentes: (pendiente revisar historial de búsqueda y marcadores)
# Función occ_prob_and_drift
def occ_prob_and_drift(x: pd.Series) -> dict:
"""
Resume, para una serie mensual x (costes), con qué frecuencia “ocurre gasto”
y si esa probabilidad está cambiando en el tiempo.
Devuelve el valor de la probabilidad media de la ocurrencia p_occurrence
(que es un valor entre 0 y 1) y el valor de occurrence_slope = \β1, que es
el cambio promedio por mes en la probabilidad de que haya gasto.
"""
# Probabilidad de ocurrencia (>0) y pendiente de su serie indicadora (posible obsolescencia)
y = (x.values > 0).astype(float)
p = float(y.mean())
if len(y) < 3:
slope = 0.0
else:
try:
t = np.arange(len(y))
slope, _ = np.polyfit(t, y, 1) # OLS sobre indicador
slope = float(slope)
except Exception:
slope = 0.0
return {"p_occurrence": p, "occurrence_slope": slope}
Función zero_ratio
¶
Definimos una función que nos devuelve la proporción de meses con coste = 0 en la serie mensual.
Complementa el resto de KPIs:
Nos ayuda a detectar intermitencia estructural en el gasto. Es un KPI crítico porque si hay muchos ceros, las funciones de outliers (MAD) o tendencia pierden fiabilidad. Nos sirve además como criterio de escape: cuando la intermitencia es extrema, no conviene modelar la serie individualmente.
Interpretación
zero_ratio ≈ 0.0 - 0.1 -> Serie regular, casi siempre hay gasto.
zero_ratio ≈ 0.2 - 0.4 -> Intermitencia moderada: conviene Croston o SBA.
zero_ratio ≥ 0.5 -> Intermitencia fuerte: serie con muchos meses sin gasto; mejor Croston/SBA o, si es extremo, usar pooling global o naive=0.
NaN (serie vacía) -> Escape, no modelable.
Manejo de errores:
Si zero_ratio
es alto y además los demás KPIs devuelven 0 o NaN → clasificar como “intermitente escape” → excluir de modelización individual y resolver con un criterio global de ML.
Fuentes:
def zero_ratio(x):
"""
Calcula el porcentaje de meses con gasto = 0 en la serie mensual x.
Devuelve un float entre 0 y 1.
"""
if len(x) == 0:
return float('nan')
return (x == 0).sum() / len(x)
Generamos 3 nuevas funciones que nos definen variables de contexto (VAR_CTX) que nos permitan tomar mejores decisiones a la hora de decidir sobre una serie.¶
Función has_min_history
¶
# ---------------------------------------------------------------------
# VAR_CTX DE ELEGIBILIDAD 1: has_min_history
# ---------------------------------------------------------------------
def has_min_history(x: pd.Series,
min_months: int = 12,
require_consecutive: bool = False) -> int:
"""
Devuelve 1 si la serie x tiene al menos `min_months` puntos temporales tras
la preparación (reindex mensual); en caso contrario devuelve 0.
Parámetros
----------
x : pd.Series
Serie mensual de costes ya reindexada. El índice debe ser DatetimeIndex
con frecuencia mensual. Los valores deben ser numéricos (floats).
min_months : int, por defecto 12
Número mínimo de observaciones para considerar la serie modelable.
require_consecutive : bool, por defecto False
Si True, exige que exista al menos un tramo con `min_months` meses
consecutivos no nulos (valor > 0). Es útil cuando en los casos que hay
muchos huecos = 0 que no aportan señal para el aprendizaje.
Retorno
-------
int
1 si cumple el criterio (apta), 0 si no (escape).
"""
# Vía de escape si la serie es None o vacía
if x is None or len(x) == 0:
return 0
# Normalizamos a float y gestionamos NaN
x = pd.to_numeric(x, errors="coerce")
# Criterio simple por longitud total (incluye ceros)
if not require_consecutive:
return int(len(x.dropna()) >= min_months)
# Criterio alternativo: tramo consecutivo con "señal" (valor > 0)
# Construimos una máscara booleana de "mes con señal"
mask_signal = (x.fillna(0) > 0).astype(int).values
# Calculamos la racha máxima (run length) de 1s consecutivos
max_run = 0
current = 0
for v in mask_signal:
if v == 1:
current += 1
max_run = max(max_run, current)
else:
current = 0
return int(max_run >= min_months)
Función effective_value_ratio
¶
# ---------------------------------------------------------------------
# VAR_CTX DE ELEGIBILIDAD 2: effective_value_ratio
# ---------------------------------------------------------------------
def effective_value_ratio(x: pd.Series, thr: float = 0.0) -> float:
"""
Proporción de meses "con señal" en la serie x, entendiendo por señal
un valor estrictamente mayor que `thr`. Por ejemplo, thr=0.0 implica
contar meses con coste > 0.
Devuelve un float en [0, 1]. Si la serie está vacía, devuelve np.nan.
Parámetros
----------
x : pd.Series
Serie mensual reindexada y numérica.
thr : float, por defecto 0.0
Umbral mínimo para considerar que un mes aporta señal.
Retorno
-------
float
(# meses con valor > thr) / (# meses totales con valor no NaN)
"""
if x is None or len(x) == 0:
return np.nan
x = pd.to_numeric(x, errors="coerce")
n = x.notna().sum()
if n == 0:
return np.nan
effective = (x.fillna(0) > thr).sum()
return float(effective) / float(n)
Función total_cost_guard
¶
# ---------------------------------------------------------------------
# VAR_CTX DE ELEGIBILIDAD 3: total_cost_guard
# ---------------------------------------------------------------------
def total_cost_guard(x: pd.Series,
min_sum: float = 0.0,
window_months: Optional[int] = None) -> float:
"""
Suma total (o suma de la ventana más reciente) de la serie x. Permite
fijar un umbral mínimo `min_sum` para filtrar series irrelevantes por
cuantía económica.
Devuelve la suma (float). Si la serie está vacía, devuelve np.nan.
Parámetros
----------
x : pd.Series
Serie mensual reindexada y numérica.
min_sum : float, por defecto 0.0
Umbral de suma mínimo recomendado para marcar elegibilidad.
(La decisión binaria se hace fuera, con la suma devuelta)
window_months : int opcional
Si se indica, se suma solo la ventana más reciente de `window_months`
meses (p.ej., últimos 12 o 24). Si None, se suma toda la serie.
Retorno
-------
float
Suma (total o en ventana). Útil para comparar contra `min_sum`.
"""
if x is None or len(x) == 0:
return np.nan
x = pd.to_numeric(x, errors="coerce").fillna(0.0)
if window_months is not None and window_months > 0:
x = x.tail(window_months)
return float(x.sum())
Integramos las funciones VAR_CTX en una sola función que nos permitirá disponer de un unico indicador de contexto binario.¶
Función elegibilidad_global
¶
# ---------------------------------------------------------------------
# VAR_CTX de ENVOLTORIO: elegibilidad_global
# ---------------------------------------------------------------------
def elegibilidad_global(x: pd.Series,
min_months: int = 12,
require_consecutive: bool = False,
eff_thr: float = 0.0,
eff_min_ratio: float = 0.25,
min_sum: float = 0.0,
sum_window_months: Optional[int] = None) -> Dict[str, Any]:
"""
Evalúa múltiples criterios de elegibilidad y los devuelve en un dict.
Está pensado para integrarlo en el pipeline antes de los KPIs de
comportamiento (tendencia, estacionalidad, intermitencia, etc.).
Reglas recomendadas (ajustables):
- has_min_history: al menos `min_months` observaciones (o racha de señal)
- effective_value_ratio: al menos `eff_min_ratio` de meses con señal
- total_cost_guard: suma mínima `min_sum` (total o en ventana)
Parámetros
----------
x : pd.Series
Serie mensual reindexada y numérica.
min_months : int
Longitud mínima exigida.
require_consecutive : bool
Si True, exige racha consecutiva de longitud min_months con señal.
eff_thr : float
Umbral para definir "señal" en effective_value_ratio (default 0.0).
eff_min_ratio : float
Ratio mínimo aceptable de meses con señal (default 0.25 = 25%).
min_sum : float
Suma mínima exigida (0.0 desactiva el filtro por cuantía).
sum_window_months : int opcional
Ventana reciente para la suma (si None, usa toda la serie).
Retorno
-------
Dict[str, Any]
{
'has_min_history': 0/1,
'effective_value_ratio': float,
'passes_effective_ratio': 0/1,
'total_sum': float,
'passes_min_sum': 0/1,
'eligible_all': 0/1 # intersección de los criterios activos
}
"""
# 1) Historial mínimo
flag_hist = has_min_history(
x, min_months=min_months, require_consecutive=require_consecutive
)
# 2) Ratio de meses con señal
eff_ratio = effective_value_ratio(x, thr=eff_thr)
# Si eff_ratio es NaN, no pasa el criterio
passes_eff = int((not np.isnan(eff_ratio)) and (eff_ratio >= eff_min_ratio))
# 3) Guardia por suma total o en ventana
total_sum = total_cost_guard(x, min_sum=min_sum, window_months=sum_window_months)
# Si min_sum <= 0, desactivamos el filtro (lo consideramos pasado)
if min_sum <= 0.0 or np.isnan(total_sum):
passes_sum = int(min_sum <= 0.0 and not np.isnan(total_sum))
else:
passes_sum = int(total_sum >= min_sum)
# 4) Elegibilidad global: intersección (AND) de los criterios activos
# - Historial mínimo siempre activo
# - Ratio efectivo activo si eff_min_ratio > 0
# - Guardia por suma activa si min_sum > 0
active_flags = [flag_hist]
if eff_min_ratio > 0.0:
active_flags.append(passes_eff)
if min_sum > 0.0:
active_flags.append(passes_sum)
eligible_all = int(all(v == 1 for v in active_flags))
return {
"has_min_history": int(flag_hist),
"effective_value_ratio": float(eff_ratio) if not np.isnan(eff_ratio) else np.nan,
"passes_effective_ratio": int(passes_eff),
"total_sum": float(total_sum) if not np.isnan(total_sum) else np.nan,
"passes_min_sum": int(passes_sum),
"eligible_all": int(eligible_all)
}
# ---------------------------------------------------------------------
# EJEMPLO DE USO
# ---------------------------------------------------------------------
# Supongamos que `s` es una serie mensual de costes:
# s.index -> pd.date_range('2021-01-01', periods=36, freq='MS')
# s.values -> floats con costes mensuales (incluye 0 y NaN tras reindex)
#
# 1) Comprobar solo historia mínima:
# has_min = has_min_history(s, min_months=12, require_consecutive=False)
#
# 2) Proporción de meses con señal (>0):
# eff = effective_value_ratio(s, thr=0.0)
#
# 3) Suma total en últimos 12 meses:
# suma12 = total_cost_guard(s, min_sum=0.0, window_months=12)
#
# 4) Chequeo global (recomendado en pipeline):
# flags = elegibilidad_global(
# s,
# min_months=12,
# require_consecutive=False,
# eff_thr=0.0,
# eff_min_ratio=0.25,
# min_sum=0.0, # ejemplo: desactivado
# sum_window_months=12 # ejemplo: mirar últimos 12 meses
# )
# if flags["eligible_all"] == 0:
# # Marcar la serie como "escape" y resolver con pooling/global
# pass
(Pendiente) incorporar tabla de referencias para definir el nivel de clasificación del modelo en función de la serie.¶
Mapa de Diagnósis de series con KPIs y VAR_CTX con Modelo Predictivo Sugerido (versión Alto Nivel Decisión).¶
Este mapa de decisión se ha basado en: XXXX
Y deja fuera algunos de los KPIs calculados que luego exponemos como se podrían añadir para ir ajustando a modelos mejores en caso que los resultados no tengan buenas métricas de validación.
Secuencia de evaluación del árbol de decisión:
Vamos a definir esta secuencia, que parece la más coherente con una visión en cascada de la toma de decisión del modelo óptimo según performance de la serie:
(1) escape -> (2) intermitencia -> (3) outliers -> (4) tendencia -> (5) estacionalidad -> (6) abonos -> (7) estable
NOTA: Cambiar este orden alteraría los resultados del diagnóstico resultante y por lo tanto modificaría la propuesta de modelo a implementar.
Mapa de decisiones (del diagnóstico -> al modelo predictivo)
Elegibilidad mínima (escape)
Condición: eligible_all
== 0.
Diagnóstico: escape_no_elegible.
Modelo recomendado: pooling_global|naive_0.
Nota: evitamos el modelaje individual en estas series; se resolverá vía pooling/naive.
Intermitencia
Condición principal (una de las dos): Verificamos si hay o no intermitencia.
zero_ratio
>= 0.5 oADI_n_over_d
> 1.32.
Rama 2.1 - Verificamos si la intermitencia es con ocurrencia al alza:
Condición adicional: p_occurrence
< 0.8 y occurrence_slope
> 0.
Diagnóstico: intermitente_creciente.
Modelo recomendado: Croston|SBA.
Rama 2.2 - Verificamos si es intermitencia general:
Diagnóstico: intermitente.
Modelo recomendado: Croston|SBA.
Picos / Atípicos
Condición: outlier_rate
>= 0.2 (con z-score robusto
).
Diagnóstico: picos_outliers.
Modelo recomendado: transform_log1p+winsor|robusto(ETS/TBATS).
Tendencia (relativa)
Cálculamos: slope_rel
= slope
/ (abs(mean
) + 1e-9) si mean
no es NaN; si mean
es NaN → slope_rel
= 0.
Condición: abs(slope_rel
) >= 0.05 (≈5% de la media por mes).
Diagnóstico: tendencia.
Modelo recomendado: Holt(ETS)|ARIMA_drift.
Estacionalidad
Condición: seasonality_strength
>= 0.6 o acf12
>= 0.3.
Diagnóstico: estacional.
Modelo recomendado: ETS estacional|SARIMA.
Abonos / Negativos relevantes
Condición: neg_share
>= 0.2 (≥20% de meses con coste negativo).
Diagnóstico: abonos_relevantes.
Modelo recomendado: preproceso_abonos+modelo_robusto.
Caso por defecto
Si nada de lo anterior se cumple:
Diagnóstico: estable.
Modelo recomendado: ETS_simple|ARIMA_basico.
Umbrales utilizados
Intermitencia: zero_ratio
= (0.5), ADI_n_over_d
= (1.32).
Picos: outlier_rate
= (0.2).
Tendencia: |slope_rel
| = (0.05).
Estacionalidad: seasonality_strength' = (0.6),
acf12` = (0.3).
Negativos: neg_share
= (0.2).
NOTA: Podemos modificar los valores umbral si deseamos criterios más exigentes.
Función diagnosticar_y_sugerir_modelo
¶
# ============================================
# Diagnóstico y modelo sugerido
# ============================================
def diagnosticar_y_sugerir_modelo(row: pd.Series) -> Dict[str, str]:
eps = 1e-9
zr = float(row.get("zero_ratio", np.nan))
out_rate = float(row.get("outlier_rate", np.nan))
slope = float(row.get("slope", 0.0))
mean = float(row.get("mean", np.nan))
acf12 = float(row.get("acf12", np.nan))
seas = float(row.get("seasonality_strength", np.nan))
p_occ = float(row.get("p_occurrence", np.nan))
occ_slope = float(row.get("occurrence_slope", np.nan))
adi_n_over_d = float(row.get("ADI_n_over_d", np.nan))
neg_share = float(row.get("neg_share", 0.0))
if int(row.get("eligible_all", 1)) == 0:
return {"diagnosis": "escape_no_elegible", "model_suggested": "pooling_global|naive_0"}
if (not np.isnan(zr) and zr >= 0.5) or (not np.isnan(adi_n_over_d) and adi_n_over_d > 1.32):
if (not np.isnan(p_occ) and not np.isnan(occ_slope)) and (p_occ < 0.8 and occ_slope > 0):
return {"diagnosis": "intermitente_creciente", "model_suggested": "Croston|SBA"}
return {"diagnosis": "intermitente", "model_suggested": "Croston|SBA"}
if not np.isnan(out_rate) and out_rate >= 0.2:
return {"diagnosis": "picos_outliers", "model_suggested": "transform_log1p+winsor|robusto(ETS/TBATS)"}
slope_rel = slope / (abs(mean) + eps) if not np.isnan(mean) else 0.0
if abs(slope_rel) >= 0.05:
return {"diagnosis": "tendencia", "model_suggested": "Holt(ETS)|ARIMA_drift"}
if (not np.isnan(seas) and seas >= 0.6) or (not np.isnan(acf12) and acf12 >= 0.3):
return {"diagnosis": "estacional", "model_suggested": "ETS estacional|SARIMA"}
if neg_share >= 0.2:
return {"diagnosis": "abonos_relevantes", "model_suggested": "preproceso_abonos+modelo_robusto"}
return {"diagnosis": "estable", "model_suggested": "ETS_simple|ARIMA_basico"}
Función calcular_kpis_y_diagnostico
¶
# ============================================
# Pipeline de cálculo KPIs + diagnóstico
# ============================================
def calcular_kpis_y_diagnostico(df_train_fd1_v5_ITE1: pd.DataFrame,
min_months: int = 12,
eff_min_ratio: float = 0.25,
require_consecutive: bool = False,
min_sum: float = 0.0,
sum_window_months: Optional[int] = None) -> pd.DataFrame:
kpis = []
for (bid, ctype), g in df_train_fd1_v5_ITE1.groupby(["ID_BUILDING", "FM_COST_TYPE"]):
s = g.set_index("FECHA")["cost_float_mod"].sort_index()
n = len(s)
zeros_share = float((s == 0).mean()) if n else np.nan
neg_share = float((s < 0).mean()) if n else np.nan
mean = float(s.mean()) if n else np.nan
std = float(s.std(ddof=1)) if n > 1 else np.nan
cv = float(std/mean) if (n and mean not in [0, np.nan] and not np.isnan(mean)) else np.nan
acf1 = safe_autocorr(s, 1)
acf12 = safe_autocorr(s, 12)
fs = seasonal_strength_stl(s, period=12)
out_rate = robust_outlier_rate(s)
slope = trend_slope(s)
adi = adi_metrics(s)
cv2_pos = cv2_positivos(s)
max_zeros = max_zero_run(s)
occ_stats = occ_prob_and_drift(s)
zr = zero_ratio(s)
flags = elegibilidad_global(s, min_months, require_consecutive, 0.0, eff_min_ratio, min_sum, sum_window_months)
dmin, dmax = (s.index.min(), s.index.max()) if n else (pd.NaT, pd.NaT)
row = {
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"date_min": dmin, "date_max": dmax,
"n_months": n,
"sparsity": zeros_share,
"zero_ratio": zr,
"neg_share": neg_share,
"mean": mean, "std": std, "cv": cv,
"acf1": acf1, "acf12": acf12,
"seasonality_strength": fs,
"outlier_rate": out_rate,
"slope": slope,
"ADI_n_over_d": adi.get("ADI_n_over_d", np.nan) if isinstance(adi, dict) else np.nan,
"ADI_mean_gap": adi.get("ADI_mean_gap", np.nan) if isinstance(adi, dict) else np.nan,
"demand_months": adi.get("demand_months", np.nan) if isinstance(adi, dict) else np.nan,
"cv2_positivos": cv2_pos,
"max_zero_run": max_zeros,
"p_occurrence": occ_stats.get("p_occurrence", np.nan) if isinstance(occ_stats, dict) else np.nan,
"occurrence_slope": occ_stats.get("occurrence_slope", np.nan) if isinstance(occ_stats, dict) else np.nan,
**flags,
"COUNTRY_DEF": g["COUNTRY_DEF"].mode().iloc[0] if not g["COUNTRY_DEF"].mode().empty else np.nan,
"ID_REGION_GRUPO": g["ID_REGION_GRUPO"].mode().iloc[0] if not g["ID_REGION_GRUPO"].mode().empty else np.nan,
"TIPO_USO": g["TIPO_USO"].mode().iloc[0] if not g["TIPO_USO"].mode().empty else np.nan,
"SUPPLIER_TYPE_MOD_2": g["SUPPLIER_TYPE_MOD_2"].mode().iloc[0] if not g["SUPPLIER_TYPE_MOD_2"].mode().empty else np.nan,
"FM_RESPONSIBLE_MOD": g["FM_RESPONSIBLE_MOD"].mode().iloc[0] if not g["FM_RESPONSIBLE_MOD"].mode().empty else np.nan,
}
decision = diagnosticar_y_sugerir_modelo(pd.Series(row))
row.update(decision)
kpis.append(row)
return pd.DataFrame(kpis).sort_values(["FM_COST_TYPE", "ID_BUILDING"]).reset_index(drop=True)
KPIs aparcados en el primer mapa de decisiones¶
cv2_positivos
Para qué: distinguir tipos de intermitencia (smooth / errática / lumpy) junto con ADI_n_over_d
.
Regla sugerida (Syntetos-Boylan):
ADI > 1.32 & CV2 > 0.49 → lumpy (Croston/SBA).
ADI > 1.32 & CV2 ≤ 0.49 → errática (Croston/SBA).
ADI ≤ 1.32 & CV2 > 0.49 → irregular (Croston/SBA o ETS simple si ocurrencia alta).
ADI ≤ 1.32 & CV2 ≤ 0.49 → smooth (ETS/ARIMA).
Impacto: refina cuándo Croston es imprescindible y cuándo ETS basta.
max_zero_run
Mide la rachas de ceros (no solo el %).
Regla: si max_zero_run
≥ 6-9 meses -> intermitencia de rachas (priorizar Croston/SBA incluso si zero_ratio
no es extremo).
Impacto: reduce falsos “estables” con periodos largos sin gasto.
acf1
Señal de persistencia AR(1) (picos de autocorrelación a 1 mes) útil para elegir ARIMA vs ETS en series sin estacionalidad.
Regla: si acf1
≥ 0.4 y sin estacionalidad -> favorecer ARIMA no estacional; si bajo -> basta con ETS simple.
Impacto: modelado más fino de series “estables”.
ADI_mean_gap
y demand_months
Complementa el diagnóstico de intermitencia y estabilidad.
Reglas:
demand_months
< 6 en 36 meses -> escape o pooling.
ADI_mean_gap
alto, pero p_occurrence
creciente -> intermitente_creciente (Croston + revisar TSB si implementas).
Impacto: decide eligibilidad y matiza intermitencia.
cv
/ std
Sirve para priorizar transformaciones (log1p, winsor) incluso si outlier_rate
no es alto, pero la variabilidad es extrema.
Regla: cv
> 2 (o percentil 90 de tu distribución) -> aplicar log1p previo al modelo.
Impacto: reduce errores en series muy volátiles sin ser estrictamente outliers.
occurrence_slope
ya lo usamos, pero también lo podríamos añadir para gobernar la elección de variante de Croston
Aplica cuando la ocurrencia sube o baja.
Regla:
occurrence_slope
> 0 -> intermitente en aumento -> considerar TSB (si lo tienes) o Croston con ajuste.
occurrence_slope
< 0 -> vigilar escape si además zero_ratio
sube.
Impacto: mejor ajuste temporal de la intermitencia.
seasonality_strength
Ya decide estacionalidad, pero también lo podríamos añadir como confirmación por acf12
(que ya incorporamos) y mes pico
Si necesitaramos features explicativas (mes del año) para un modelo ML en pooling.
Regla: guardar mes de máxima media como feature para modelos globales.
Aplicamos diagnóstico y modelo sugerido a todas las series del dataframe df_train_fd1_v5_ITE1
.¶
# === 0) IMPORTS (aseguremos de tener previamente definidas las funciones KPI base) ===
# Deben estar ya definidas en el notebook:
# safe_autocorr, seasonal_strength_stl, robust_outlier_rate, trend_slope,
# adi_metrics, cv2_positivos, max_zero_run, occ_prob_and_drift,
# zero_ratio, has_min_history,
# effective_value_ratio, total_cost_guard, elegibilidad_global,
# diagnosticar_y_sugerir_modelo, calcular_kpis_y_diagnostico
# === 1) CHEQUEOS RÁPIDOS DEL DF (nos evitamos sorpresas) ===
required_cols = {
"ID_BUILDING","FM_COST_TYPE","FECHA","cost_float_mod",
"COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"
}
missing = required_cols - set(df_train_fd1_v5_ITE1.columns)
assert not missing, f"Faltan columnas en df_train_fd1_v5_ITE1: {missing}"
# Aseguramos tipo datetime
if not np.issubdtype(df_train_fd1_v5_ITE1["FECHA"].dtype, np.datetime64):
df_train_fd1_v5_ITE1["FECHA"] = pd.to_datetime(df_train_fd1_v5_ITE1["FECHA"], errors="coerce")
assert df_train_fd1_v5_ITE1["FECHA"].notna().all(), "Hay FECHA NaT tras conversión; revisa el dataset."
# Verificamos el rango temporal esperado (2021–2023 para ITE1)
print("Rango fechas ITE1:", df_train_fd1_v5_ITE1["FECHA"].min(), "→", df_train_fd1_v5_ITE1["FECHA"].max())
# === 2) EJECUTAR LA FUNCIÓN CON PARÁMETROS BASE ===
df_result = calcular_kpis_y_diagnostico(
df_train_fd1_v5_ITE1,
min_months=12, # histórico mínimo para modelizar
eff_min_ratio=0.25, # % mínimo de meses con señal (>0)
require_consecutive=False,
min_sum=0.0, # activa guardia por cuantía si > 0
sum_window_months=None
)
print("Columnas devueltas:", list(df_result.columns))
print("Shape:", df_result.shape)
print(df_result.head(10))
# === 3) DIAGNÓSTICO GLOBAL RÁPIDO ===
print("\nDistribución de diagnósticos:")
print(df_result["diagnosis"].value_counts(dropna=False).sort_index())
print("\nModelos sugeridos (top 10):")
print(df_result["model_suggested"].value_counts().head(10))
# Casos no elegibles (escape)
mask_escape = df_result["diagnosis"] == "escape_no_elegible"
print("\n% series no elegibles:", round(100 * mask_escape.mean(), 2), "%")
# Intermitentes (zero_ratio alto o ADI alto)
mask_intermitente = df_result["diagnosis"].str.contains("intermitente", na=False)
print("% series intermitentes:", round(100 * mask_intermitente.mean(), 2), "%")
# Estacionales
mask_estacional = df_result["diagnosis"] == "estacional"
print("% series estacionales:", round(100 * mask_estacional.mean(), 2), "%")
# === 4) SANITY CHECKS (resumen de KPIs clave) ===
kpis_overview = df_result[[
"zero_ratio","outlier_rate","slope","seasonality_strength",
"acf12","ADI_n_over_d","neg_share","effective_value_ratio","eligible_all"
]].describe(include="all")
print("\nResumen KPIs clave:\n", kpis_overview)
# Vemos ejemplos de cada diagnóstico para inspección manual
ejemplos = {}
for etiqueta in ["escape_no_elegible","intermitente","intermitente_creciente","picos_outliers","tendencia","estacional","abonos_relevantes","estable"]:
ejemplos[etiqueta] = df_result[df_result["diagnosis"] == etiqueta].head(3)
# Muestra un ejemplo concreto (cambia la etiqueta por la que te interese)
print("\nEjemplos de 'intermitente':")
print(ejemplos["intermitente"][[
"ID_BUILDING","FM_COST_TYPE","zero_ratio","ADI_n_over_d","p_occurrence","occurrence_slope","diagnosis","model_suggested"
]])
# === 5) AJUSTES FINOS DE LOS UMBRALES (si fuera necesario) ===
# Si vemos demasiadas series clasificadas como 'picos_outliers', baja o sube el umbral en la función diagnosticar_y_sugerir_modelo
# (por ejemplo, pasar outlier_rate >= 0.2 a 0.25 o 0.15).
# Lo mismo para:
# - intermitencia: zero_ratio >= 0.5, ADI_n_over_d > 1.32
# - tendencia relativa: abs(slope_rel) >= 0.05
# - estacionalidad: seasonality_strength >= 0.6 o acf12 >= 0.3
Rango fechas ITE1: 2021-01-01 00:00:00 → 2023-12-01 00:00:00 Columnas devueltas: ['ID_BUILDING', 'FM_COST_TYPE', 'date_min', 'date_max', 'n_months', 'sparsity', 'zero_ratio', 'neg_share', 'mean', 'std', 'cv', 'acf1', 'acf12', 'seasonality_strength', 'outlier_rate', 'slope', 'ADI_n_over_d', 'ADI_mean_gap', 'demand_months', 'cv2_positivos', 'max_zero_run', 'p_occurrence', 'occurrence_slope', 'has_min_history', 'effective_value_ratio', 'passes_effective_ratio', 'total_sum', 'passes_min_sum', 'eligible_all', 'COUNTRY_DEF', 'ID_REGION_GRUPO', 'TIPO_USO', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'diagnosis', 'model_suggested'] Shape: (2429, 36) ID_BUILDING FM_COST_TYPE date_min date_max n_months \ 0 2 Eficiencia Energética 2021-12-01 2021-12-01 1 1 9 Eficiencia Energética 2022-10-01 2023-12-01 15 2 18 Eficiencia Energética 2021-01-01 2023-05-01 29 3 57 Eficiencia Energética 2021-04-01 2021-04-01 1 4 126 Eficiencia Energética 2021-04-01 2023-11-01 32 5 129 Eficiencia Energética 2023-11-01 2023-11-01 1 6 135 Eficiencia Energética 2021-04-01 2021-04-01 1 7 136 Eficiencia Energética 2021-04-01 2023-11-01 32 8 137 Eficiencia Energética 2021-04-01 2023-11-01 32 9 146 Eficiencia Energética 2022-05-01 2022-05-01 1 sparsity zero_ratio neg_share mean std ... total_sum \ 0 1.00000 1.00000 0.0 0.000000 NaN ... 0.00 1 1.00000 1.00000 0.0 0.000000 0.000000 ... 0.00 2 1.00000 1.00000 0.0 0.000000 0.000000 ... 0.00 3 0.00000 0.00000 0.0 218.780000 NaN ... 218.78 4 0.84375 0.84375 0.0 1061.812500 2506.910653 ... 33978.00 5 1.00000 1.00000 0.0 0.000000 NaN ... 0.00 6 0.00000 0.00000 0.0 254.610000 NaN ... 254.61 7 0.84375 0.84375 0.0 1350.000000 3187.313562 ... 43200.00 8 0.81250 0.81250 0.0 494.483125 1144.964316 ... 15823.46 9 1.00000 1.00000 0.0 0.000000 NaN ... 0.00 passes_min_sum eligible_all COUNTRY_DEF ID_REGION_GRUPO TIPO_USO \ 0 1 0 España 2 Oficinas 1 1 0 España 2 Oficinas 2 1 0 España 2 Oficinas 3 1 0 España 2 Subarrendado 4 1 0 España 12 Bingo 5 1 0 España Otros_España Bingo 6 1 0 España 2 Bingo 7 1 0 España 2 Bingo 8 1 0 España 2 Bingo 9 1 0 España 4 Bingo SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD diagnosis \ 0 INTERNO Eficiencia Energética escape_no_elegible 1 INTERNO Eficiencia Energética escape_no_elegible 2 INTERNO Eficiencia Energética escape_no_elegible 3 EXTERNO Eficiencia Energética escape_no_elegible 4 EXTERNO Eficiencia Energética escape_no_elegible 5 INTERNO Eficiencia Energética escape_no_elegible 6 EXTERNO Eficiencia Energética escape_no_elegible 7 EXTERNO Eficiencia Energética escape_no_elegible 8 EXTERNO Eficiencia Energética escape_no_elegible 9 EXTERNO Eficiencia Energética escape_no_elegible model_suggested 0 pooling_global|naive_0 1 pooling_global|naive_0 2 pooling_global|naive_0 3 pooling_global|naive_0 4 pooling_global|naive_0 5 pooling_global|naive_0 6 pooling_global|naive_0 7 pooling_global|naive_0 8 pooling_global|naive_0 9 pooling_global|naive_0 [10 rows x 36 columns] Distribución de diagnósticos: diagnosis escape_no_elegible 870 estable 500 estacional 489 intermitente 205 intermitente_creciente 274 picos_outliers 60 tendencia 31 Name: count, dtype: int64 Modelos sugeridos (top 10): model_suggested pooling_global|naive_0 870 ETS_simple|ARIMA_basico 500 ETS estacional|SARIMA 489 Croston|SBA 479 transform_log1p+winsor|robusto(ETS/TBATS) 60 Holt(ETS)|ARIMA_drift 31 Name: count, dtype: int64 % series no elegibles: 35.82 % % series intermitentes: 19.72 % % series estacionales: 20.13 % Resumen KPIs clave: zero_ratio outlier_rate slope seasonality_strength \ count 2429.000000 2429.000000 2429.000000 1741.000000 mean 0.358087 0.036275 20.647309 0.610967 std 0.368764 0.079214 86.772806 0.293074 min 0.000000 0.000000 -1191.035000 0.000000 25% 0.000000 0.000000 -0.141832 0.410353 50% 0.166667 0.000000 1.550636 0.646491 75% 0.750000 0.028571 15.062425 0.859926 max 1.000000 0.484848 779.582005 1.000000 acf12 ADI_n_over_d neg_share effective_value_ratio \ count 1874.000000 2429.0 2429.000000 2429.000000 mean 0.153561 inf 0.000418 0.641495 std 0.437260 NaN 0.010464 0.368543 min -1.000000 1.0 0.000000 0.000000 25% -0.156602 1.0 0.000000 0.250000 50% 0.043378 1.2 0.000000 0.833333 75% 0.518020 4.0 0.000000 1.000000 max 1.000000 inf 0.500000 1.000000 eligible_all count 2429.000000 mean 0.641828 std 0.479562 min 0.000000 25% 0.000000 50% 1.000000 75% 1.000000 max 1.000000 Ejemplos de 'intermitente': ID_BUILDING FM_COST_TYPE zero_ratio ADI_n_over_d p_occurrence \ 70 2 Licencias 0.742857 3.888889 0.257143 71 9 Licencias 0.638889 2.769231 0.361111 72 18 Licencias 0.722222 3.600000 0.277778 occurrence_slope diagnosis model_suggested 70 -0.022409 intermitente Croston|SBA 71 -0.001158 intermitente Croston|SBA 72 -0.010039 intermitente Croston|SBA
Interpretación de los resultados del diagnóstico¶
- Rango temporal Rango fechas ITE1: 2021-01-01 → 2023-12-01
Las series están bien construidas para el histórico de entrenamiento de la iteración 1 (2021-2023), en línea con lo que definimos para el proyecto.
- Tamaño del dataset Shape: (2429, 36)
Se han generado 2.429 parejas (ID_BUILDING
, FM_COST_TYPE
) con todos los KPIs y diagnosis.
- Ejemplos de filas
Muchas series tienen n_months = 1 (ej. ID_BUILDING
= 2 o 57).
Para esas: zero_ratio
= 1.0, mean
= 0, total_sum
= 0.
Resultado: eligible_all
= 0 -> diagnosis = escape_no_elegible -> modelo sugerido = pooling_global|naive_0.
- Distribución de diagnósticos
- escape_no_elegible 870 (35.8%) -> no elegibles
- estable 500 (20.6%) -> regular
- estacional 489 (20.1%) -> regular
- intermitente 205 (8.4%) -> intermitente
- intermitente_creciente 274 (11.3%) -> intermitente
- picos_outliers 60 (2.5%) -> con picos
- tendencia 31 (1.3%) -> con tendencia clara
Interpretación del diagnóstico:
36% no elegibles: muchísimas series cortas o sin señal -> debemos tratarlas con pooling/naive.
40% regulares (estable + estacional) -> son las que mejor nos encajan con métodos ETS/ARIMA/SARIMA.
20% intermitentes -> es clave que apliquemos Croston/SBA.
2.5% con picos -> requieren modelos robustos o transformaciones.
1% con tendencia clara -> pocos casos de Holt/ARIMA con drift.
- KPIs agregados
zero_ratio: media 0.36, mediana 0.17 → bastante intermitencia.
seasonality_strength: media 0.61, mediana 0.65 → estacionalidad fuerte en muchas series.
outlier_rate: muy bajo en promedio (0.036), pero con colas largas.
ADI_n_over_d: muchos valores → ∞ o muy altos en series con mucha dispersión → confirma intermitencia.
eligible_all: media 0.64 → el 64% de series pasa filtros básicos.
- Ejemplo de intermitentes
El output de ejemplo muestra series con:
zero_ratio ≈ 0.7 ADI_n_over_d > 3 p_occurrence ≈ 0.25-0.36 occurrence_slope negativo
El diagnóstico es correcto, serie intermitente -> el modelo sugerido es Croston|SBA.
Conclusiones:
El pipeline clasifica bien según la lógica que hemos definido.
La foto global que obtenemos nos confirma que el 33% de las series van a pooling, el 33% son regulares y van con ETS/ARIMA, y el 33% restante son intermitentes y van con Croston.
Guardamos el dataframe resultante, con un versionado para asociarlo con los umbrales de la clasificación realizada.¶
# Guardamos el diagnóstico y modelado sugerido en un dataframe específico y único para guardar al Drive
df_result_train_fd1_v5_ITE1 = df_result.copy()
# Carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
os.makedirs(OUT_DIR, exist_ok=True)
# Rutas de salida (CSV + Excel único con varias hojas)
csv_path = os.path.join(OUT_DIR, "df_result_train_fd1_v5_ITE1.csv")
xlsx_path = os.path.join(OUT_DIR, "df_result_train_fd1_v5_ITE1.xlsx")
# Guardar CSV principal
df_result_train_fd1_v5_ITE1.to_csv(csv_path, sep=";",index=False)
# Resumen por diagnosis y modelo
resumen_por_diagnosis = (
df_result_train_fd1_v5_ITE1
.groupby("diagnosis", dropna=False)
.size()
.rename("series")
.reset_index()
.sort_values("series", ascending=False)
)
resumen_por_modelo = (
df_result_train_fd1_v5_ITE1
.groupby(["diagnosis","model_suggested"], dropna=False)
.size()
.rename("series")
.reset_index()
.sort_values(["diagnosis","series"], ascending=[True,False])
)
# Guardar también resúmenes en CSV separados
csv_diag_sum = os.path.join(OUT_DIR, "resumen_por_diagnosis_ITE1.csv")
csv_mod_sum = os.path.join(OUT_DIR, "resumen_por_modelo_ITE1.csv")
resumen_por_diagnosis.to_csv(csv_diag_sum, sep=";", index=False)
resumen_por_modelo.to_csv(csv_mod_sum, sep=";", index=False)
# Excel único con varias pestañas (df_result + resúmenes)
# Nota: si alguna columna datetime tuviera tz, Excel podría fallar; en ese caso deberíamos deszonificar antes.
try:
with pd.ExcelWriter(xlsx_path, engine="openpyxl") as writer:
df_result_train_fd1_v5_ITE1.to_excel(writer, sheet_name="df_result", index=False)
resumen_por_diagnosis.to_excel(writer, sheet_name="resumen_diagnosis", index=False)
resumen_por_modelo.to_excel(writer, sheet_name="resumen_modelo", index=False)
except Exception as e:
print(f"[AVISO] No se pudo guardar Excel: {type(e).__name__}: {e}")
print("\n[OK] df_result guardado.")
print(" - CSV:", csv_path)
print(" - Excel (multi-hojas):", xlsx_path)
print(" - Resúmenes también en CSV (opcional).")
[OK] df_result guardado. - CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_result_train_fd1_v5_ITE1.csv - Excel (multi-hojas): /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_result_train_fd1_v5_ITE1.xlsx - Resúmenes también en CSV (opcional).
Generamos el notebook en HTML¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_train_con_KPI_Diagnostico_Modelo.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 1 image(s). [NbConvertApp] Writing 1430970 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_train_con_KPI_Diagnostico_Modelo.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_train_con_KPI_Diagnostico_Modelo.html Existe: True
Cargamos el dataframe de entrenamiento para el conjunto de series de 2021 a 2023 con el diagnóstico de su performance y el modelo sugerido según el arbol de decisiones de alto nivel.¶
# === CARGAMOS DE df_result_train_fd1_v5_ITE1 (ITE1: 2021–2023) ===
# Intentamos cargar desde Excel (multi-hoja, sheet 'df_result'); si no, desde CSV.
# Ruta de salida usada previamente
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
xlsx_path = os.path.join(OUT_DIR, "df_result_train_fd1_v5_ITE1.xlsx")
csv_path = os.path.join(OUT_DIR, "df_result_train_fd1_v5_ITE1.csv")
def _read_df_result(xlsx_path: str, csv_path: str) -> pd.DataFrame:
# 1) Intentar Excel con hoja 'df_result'
if os.path.exists(xlsx_path):
try:
# Intentamos leer la hoja explícita
return pd.read_excel(xlsx_path, sheet_name="df_result")
except ValueError:
# Si no existe la hoja, leemos la primera disponible
xls = pd.ExcelFile(xlsx_path)
first_sheet = xls.sheet_names[0]
return pd.read_excel(xlsx_path, sheet_name=first_sheet)
# 2) Fallback: CSV
if os.path.exists(csv_path):
return pd.read_csv(csv_path, sep=";")
raise FileNotFoundError(
f"No se encontró ni el Excel ni el CSV esperados:\n- {xlsx_path}\n- {csv_path}"
)
df_result_train_fd1_v5_ITE1 = _read_df_result(xlsx_path, csv_path)
# --- Chequeos suaves y tipificación útil ---
# Columnas esperadas mínimas (ajusta si tu función devuelve otras)
expected_min_cols = {
"ID_BUILDING", "FM_COST_TYPE", "diagnosis", "model_suggested"
}
missing = expected_min_cols - set(df_result_train_fd1_v5_ITE1.columns)
if missing:
print(f"[AVISO] Faltan columnas mínimas esperadas en df_result: {missing}")
# Tipos prácticos
if "ID_BUILDING" in df_result_train_fd1_v5_ITE1.columns:
# coercible a entero
df_result_train_fd1_v5_ITE1["ID_BUILDING"] = pd.to_numeric(
df_result_train_fd1_v5_ITE1["ID_BUILDING"], errors="coerce"
).astype("Int64")
if "FM_COST_TYPE" in df_result_train_fd1_v5_ITE1.columns:
df_result_train_fd1_v5_ITE1["FM_COST_TYPE"] = df_result_train_fd1_v5_ITE1["FM_COST_TYPE"].astype(str)
# Si existen KPIs numéricos, los intentamos convertir
kpi_candidates = [
"zero_ratio","outlier_rate","slope","seasonality_strength",
"acf12","ADI_n_over_d","neg_share","effective_value_ratio"
]
for c in kpi_candidates:
if c in df_result_train_fd1_v5_ITE1.columns:
df_result_train_fd1_v5_ITE1[c] = pd.to_numeric(
df_result_train_fd1_v5_ITE1[c], errors="coerce"
)
print("[OK] Cargado df_result_train_fd1_v5_ITE1")
print("Shape:", df_result_train_fd1_v5_ITE1.shape)
print(df_result_train_fd1_v5_ITE1.head(5))
[OK] Cargado df_result_train_fd1_v5_ITE1 Shape: (2429, 36) ID_BUILDING FM_COST_TYPE date_min date_max n_months \ 0 2 Eficiencia Energética 2021-12-01 2021-12-01 1 1 9 Eficiencia Energética 2022-10-01 2023-12-01 15 2 18 Eficiencia Energética 2021-01-01 2023-05-01 29 3 57 Eficiencia Energética 2021-04-01 2021-04-01 1 4 126 Eficiencia Energética 2021-04-01 2023-11-01 32 sparsity zero_ratio neg_share mean std ... total_sum \ 0 1.00000 1.00000 0.0 0.0000 NaN ... 0.00 1 1.00000 1.00000 0.0 0.0000 0.000000 ... 0.00 2 1.00000 1.00000 0.0 0.0000 0.000000 ... 0.00 3 0.00000 0.00000 0.0 218.7800 NaN ... 218.78 4 0.84375 0.84375 0.0 1061.8125 2506.910653 ... 33978.00 passes_min_sum eligible_all COUNTRY_DEF ID_REGION_GRUPO TIPO_USO \ 0 1 0 España 2 Oficinas 1 1 0 España 2 Oficinas 2 1 0 España 2 Oficinas 3 1 0 España 2 Subarrendado 4 1 0 España 12 Bingo SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD diagnosis \ 0 INTERNO Eficiencia Energética escape_no_elegible 1 INTERNO Eficiencia Energética escape_no_elegible 2 INTERNO Eficiencia Energética escape_no_elegible 3 EXTERNO Eficiencia Energética escape_no_elegible 4 EXTERNO Eficiencia Energética escape_no_elegible model_suggested 0 pooling_global|naive_0 1 pooling_global|naive_0 2 pooling_global|naive_0 3 pooling_global|naive_0 4 pooling_global|naive_0 [5 rows x 36 columns]
Exploramos que la carga se haya realizado correctamente.
# === EXPLORACIÓN DE df_result_train_fd1_v5_ITE1 cargado ===
# Shape general
print("Shape:", df_result_train_fd1_v5_ITE1.shape)
# Columnas disponibles
print("\nColumnas:")
print(list(df_result_train_fd1_v5_ITE1.columns))
# Tipos de datos y nulos
print("\nInfo del dataframe:")
print(df_result_train_fd1_v5_ITE1.info())
# Primeras filas
print("\nPrimeras filas:")
print(df_result_train_fd1_v5_ITE1.head(5))
# Distribución de diagnósticos
if "diagnosis" in df_result_train_fd1_v5_ITE1.columns:
print("\nDistribución de diagnosis:")
print(df_result_train_fd1_v5_ITE1["diagnosis"].value_counts(dropna=False).sort_index())
# Modelos sugeridos
if "model_suggested" in df_result_train_fd1_v5_ITE1.columns:
print("\nModelos sugeridos (top 10):")
print(df_result_train_fd1_v5_ITE1["model_suggested"].value_counts().head(10))
# Resumen de KPIs clave (si existen en las columnas)
kpi_cols = [
"zero_ratio","outlier_rate","slope","seasonality_strength",
"acf12","ADI_n_over_d","neg_share","effective_value_ratio"
]
kpi_existentes = [c for c in kpi_cols if c in df_result_train_fd1_v5_ITE1.columns]
if kpi_existentes:
print("\nResumen estadístico de KPIs clave:")
print(df_result_train_fd1_v5_ITE1[kpi_existentes].describe())
Shape: (2429, 36) Columnas: ['ID_BUILDING', 'FM_COST_TYPE', 'date_min', 'date_max', 'n_months', 'sparsity', 'zero_ratio', 'neg_share', 'mean', 'std', 'cv', 'acf1', 'acf12', 'seasonality_strength', 'outlier_rate', 'slope', 'ADI_n_over_d', 'ADI_mean_gap', 'demand_months', 'cv2_positivos', 'max_zero_run', 'p_occurrence', 'occurrence_slope', 'has_min_history', 'effective_value_ratio', 'passes_effective_ratio', 'total_sum', 'passes_min_sum', 'eligible_all', 'COUNTRY_DEF', 'ID_REGION_GRUPO', 'TIPO_USO', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'diagnosis', 'model_suggested'] Info del dataframe: <class 'pandas.core.frame.DataFrame'> RangeIndex: 2429 entries, 0 to 2428 Data columns (total 36 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 ID_BUILDING 2429 non-null Int64 1 FM_COST_TYPE 2429 non-null object 2 date_min 2429 non-null datetime64[ns] 3 date_max 2429 non-null datetime64[ns] 4 n_months 2429 non-null int64 5 sparsity 2429 non-null float64 6 zero_ratio 2429 non-null float64 7 neg_share 2429 non-null float64 8 mean 2429 non-null float64 9 std 2241 non-null float64 10 cv 2210 non-null float64 11 acf1 2131 non-null float64 12 acf12 1874 non-null float64 13 seasonality_strength 1741 non-null float64 14 outlier_rate 2429 non-null float64 15 slope 2429 non-null float64 16 ADI_n_over_d 2429 non-null float64 17 ADI_mean_gap 2429 non-null float64 18 demand_months 2429 non-null int64 19 cv2_positivos 2135 non-null float64 20 max_zero_run 2429 non-null int64 21 p_occurrence 2429 non-null float64 22 occurrence_slope 2429 non-null float64 23 has_min_history 2429 non-null int64 24 effective_value_ratio 2429 non-null float64 25 passes_effective_ratio 2429 non-null int64 26 total_sum 2429 non-null float64 27 passes_min_sum 2429 non-null int64 28 eligible_all 2429 non-null int64 29 COUNTRY_DEF 2429 non-null object 30 ID_REGION_GRUPO 2429 non-null object 31 TIPO_USO 2429 non-null object 32 SUPPLIER_TYPE_MOD_2 2429 non-null object 33 FM_RESPONSIBLE_MOD 2429 non-null object 34 diagnosis 2429 non-null object 35 model_suggested 2429 non-null object dtypes: Int64(1), datetime64[ns](2), float64(18), int64(7), object(8) memory usage: 685.7+ KB None Primeras filas: ID_BUILDING FM_COST_TYPE date_min date_max n_months \ 0 2 Eficiencia Energética 2021-12-01 2021-12-01 1 1 9 Eficiencia Energética 2022-10-01 2023-12-01 15 2 18 Eficiencia Energética 2021-01-01 2023-05-01 29 3 57 Eficiencia Energética 2021-04-01 2021-04-01 1 4 126 Eficiencia Energética 2021-04-01 2023-11-01 32 sparsity zero_ratio neg_share mean std ... total_sum \ 0 1.00000 1.00000 0.0 0.0000 NaN ... 0.00 1 1.00000 1.00000 0.0 0.0000 0.000000 ... 0.00 2 1.00000 1.00000 0.0 0.0000 0.000000 ... 0.00 3 0.00000 0.00000 0.0 218.7800 NaN ... 218.78 4 0.84375 0.84375 0.0 1061.8125 2506.910653 ... 33978.00 passes_min_sum eligible_all COUNTRY_DEF ID_REGION_GRUPO TIPO_USO \ 0 1 0 España 2 Oficinas 1 1 0 España 2 Oficinas 2 1 0 España 2 Oficinas 3 1 0 España 2 Subarrendado 4 1 0 España 12 Bingo SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD diagnosis \ 0 INTERNO Eficiencia Energética escape_no_elegible 1 INTERNO Eficiencia Energética escape_no_elegible 2 INTERNO Eficiencia Energética escape_no_elegible 3 EXTERNO Eficiencia Energética escape_no_elegible 4 EXTERNO Eficiencia Energética escape_no_elegible model_suggested 0 pooling_global|naive_0 1 pooling_global|naive_0 2 pooling_global|naive_0 3 pooling_global|naive_0 4 pooling_global|naive_0 [5 rows x 36 columns] Distribución de diagnosis: diagnosis escape_no_elegible 870 estable 500 estacional 489 intermitente 205 intermitente_creciente 274 picos_outliers 60 tendencia 31 Name: count, dtype: int64 Modelos sugeridos (top 10): model_suggested pooling_global|naive_0 870 ETS_simple|ARIMA_basico 500 ETS estacional|SARIMA 489 Croston|SBA 479 transform_log1p+winsor|robusto(ETS/TBATS) 60 Holt(ETS)|ARIMA_drift 31 Name: count, dtype: int64 Resumen estadístico de KPIs clave: zero_ratio outlier_rate slope seasonality_strength \ count 2429.000000 2429.000000 2429.000000 1741.000000 mean 0.358087 0.036275 20.647309 0.610967 std 0.368764 0.079214 86.772806 0.293074 min 0.000000 0.000000 -1191.035000 0.000000 25% 0.000000 0.000000 -0.141832 0.410353 50% 0.166667 0.000000 1.550636 0.646491 75% 0.750000 0.028571 15.062425 0.859926 max 1.000000 0.484848 779.582005 1.000000 acf12 ADI_n_over_d neg_share effective_value_ratio count 1874.000000 2429.0 2429.000000 2429.000000 mean 0.153561 inf 0.000418 0.641495 std 0.437260 NaN 0.010464 0.368543 min -1.000000 1.0 0.000000 0.000000 25% -0.156602 1.0 0.000000 0.250000 50% 0.043378 1.2 0.000000 0.833333 75% 0.518020 4.0 0.000000 1.000000 max 1.000000 inf 0.500000 1.000000
Enlazamos el diagnóstico por pareja conseguido con nuestro panel de series mensuales train para partirlo en sub-dataframes según el modelo sugerido del diagnóstico.¶
Generamos los sub-dataframes para cada modelo sugerido del conjunto train.¶
Nos quedamos del diagnóstico
df_result_train_fd1_v5_ITE1
con las columnas clave:ID_BUILDING
,FM_COST_TYPE
,diagnosis
,model_suggested
.Hacemos un Merge (inner) con
df_train_fd1_v5_ITE1
por (ID_BUILDING
, FM_COST_TYPE) para añadir el modelo sugerido a cada fila mensual.Normalizamos
model_suggested
a un grupo/sufijo sencillo (ETS, SARIMA, ARIMA, POOLING, CROSTON, HOLT, ROBUSTO, OTROS).Separamos el panel en sub-dataframes por grupo y los creamos en el espacio de variables con el nombre solicitado (
df_train_fd1_v5_ITE1_
).Mostramos un resumen con conteos de series por grupo para validar.
# Preparamos el diagnóstico corto y lo unimos al panel
cols_diag = ["ID_BUILDING","FM_COST_TYPE","diagnosis","model_suggested"]
missing_diag = set(cols_diag) - set(df_result_train_fd1_v5_ITE1.columns)
assert not missing_diag, f"Faltan columnas en df_result_train_fd1_v5_ITE1: {missing_diag}"
# Nos quedamos con las columnas mínimas necesarias del diagnóstico
diag_short = df_result_train_fd1_v5_ITE1[cols_diag].drop_duplicates()
# Comprobamos que el panel de entrenamiento tiene las columnas requeridas
panel_cols_req = ["ID_BUILDING","FM_COST_TYPE","FECHA","cost_float_mod"]
missing_panel = set(panel_cols_req) - set(df_train_fd1_v5_ITE1.columns)
assert not missing_panel, f"Faltan columnas en df_train_fd1_v5_ITE1: {missing_panel}"
# Unimos el panel mensual con el diagnóstico para que cada fila mensual tenga su modelo sugerido
df_panel_model = (
df_train_fd1_v5_ITE1
.merge(diag_short, on=["ID_BUILDING","FM_COST_TYPE"], how="inner")
)
# Normalizamos el modelo sugerido a un grupo/sufijo simple
# Definimos una función que mapea cada modelo sugerido a una etiqueta corta (ETS, SARIMA, ARIMA, etc.)
def normalizar_modelo(model_suggested: str) -> str:
if not isinstance(model_suggested, str):
return "OTROS"
s = model_suggested.lower()
if "pool" in s or "naive" in s:
return "POOLING"
if "croston" in s or "sba" in s:
return "CROSTON"
if "sarima" in s:
return "SARIMA"
if "holt" in s:
return "HOLT"
if "arima" in s:
return "ARIMA"
if "robusto" in s or "winsor" in s or "tbats" in s:
return "ROBUSTO"
if "ets" in s or "holt-winters" in s:
return "ETS"
return "OTROS"
# Creamos la nueva columna de grupo de modelo
df_panel_model["model_group"] = df_panel_model["model_suggested"].map(normalizar_modelo)
# === 3) Partimos el panel por grupo y generamos dataframes con el nombre solicitado ===
base_name = "df_train_fd1_v5_ITE1"
grupos = sorted(df_panel_model["model_group"].dropna().unique())
created = {}
for g in grupos:
# Filtramos el panel para cada grupo de modelo
df_g = df_panel_model[df_panel_model["model_group"] == g].copy()
# Construimos el nombre dinámicamente (ej. df_train_fd1_v5_ITE1_ETS)
var_name = f"{base_name}_{g}"
# Creamos el dataframe en el entorno global con ese nombre
globals()[var_name] = df_g
# Guardamos referencia para mostrar resumen
created[g] = (var_name, df_g.shape)
# === 4) Visualizamos los resultados de manera agrupada ===
print("Sub-dataframes creados por modelo:")
for g,(name,shape) in created.items():
# Contamos las parejas únicas en cada sub-bloque para entender su tamaño
n_pairs = df_panel_model[df_panel_model["model_group"]==g][["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0]
print(f" - {name:32s}: {shape} | parejas únicas = {n_pairs}")
# También mostramos la distribución de grupos por número de series (parejas únicas)
print("\nDistribución de grupos (por parejas únicas):")
pairs_by_group = (
df_panel_model[["ID_BUILDING","FM_COST_TYPE","model_group"]]
.drop_duplicates()
.groupby("model_group").size().rename("series").sort_values(ascending=False)
)
print(pairs_by_group)
Sub-dataframes creados por modelo: - df_train_fd1_v5_ITE1_ARIMA : (17188, 14) | parejas únicas = 500 - df_train_fd1_v5_ITE1_CROSTON : (14460, 14) | parejas únicas = 479 - df_train_fd1_v5_ITE1_HOLT : (899, 14) | parejas únicas = 31 - df_train_fd1_v5_ITE1_POOLING : (13647, 14) | parejas únicas = 870 - df_train_fd1_v5_ITE1_ROBUSTO : (2065, 14) | parejas únicas = 60 - df_train_fd1_v5_ITE1_SARIMA : (17207, 14) | parejas únicas = 489 Distribución de grupos (por parejas únicas): model_group POOLING 870 ARIMA 500 SARIMA 489 CROSTON 479 ROBUSTO 60 HOLT 31 Name: series, dtype: int64
El resultado nos cuadra con la selección realizar según el resultado del diagnóstico, por lo tanto, ya tenemos los distintos dataframes de entrenamiento para cada modelización específica.
Guardamos en el Drive los distintos dataframes en un excel para cada grupo y luego en csvs individuales.¶
# === GUARDAMOS LOS DATAFRAMES SEGÚN LA SEGREGACIÓN POR MODELO ===
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
os.makedirs(OUT_DIR, exist_ok=True)
# Recorremos cada grupo de modelos creado en el paso anterior
for g,(var_name,shape) in created.items():
# Recuperamos el dataframe del entorno global
df_g = globals()[var_name]
# Definimos las rutas de salida con el sufijo correspondiente
csv_path = os.path.join(OUT_DIR, f"{var_name}.csv")
xlsx_path = os.path.join(OUT_DIR, f"{var_name}.xlsx")
# Guardamos en CSV
df_g.to_csv(csv_path, sep=";", index=False)
# Guardamos en Excel
try:
df_g.to_excel(xlsx_path, index=False)
except Exception as e:
print(f"[AVISO] No se pudo guardar {var_name} en Excel: {type(e).__name__}: {e}")
# Mostramos un mensaje de confirmación por cada dataframe
print(f"[OK] Guardado {var_name} | filas: {shape[0]} | columnas: {shape[1]}")
print(" - CSV (;) :", csv_path)
print(" - Excel:", xlsx_path)
[OK] Guardado df_train_fd1_v5_ITE1_ARIMA | filas: 17188 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_ARIMA.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_ARIMA.xlsx [OK] Guardado df_train_fd1_v5_ITE1_CROSTON | filas: 14460 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_CROSTON.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_CROSTON.xlsx [OK] Guardado df_train_fd1_v5_ITE1_HOLT | filas: 899 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_HOLT.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_HOLT.xlsx [OK] Guardado df_train_fd1_v5_ITE1_POOLING | filas: 13647 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_POOLING.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_POOLING.xlsx [OK] Guardado df_train_fd1_v5_ITE1_ROBUSTO | filas: 2065 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_ROBUSTO.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_ROBUSTO.xlsx [OK] Guardado df_train_fd1_v5_ITE1_SARIMA | filas: 17207 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_SARIMA.csv - Excel: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_SARIMA.xlsx
Obtenemos los dataframe de test para cada grupo de modelo sugerido del conjunto train o entrenamiento.¶
Primero vamos a realizar la misma clasificación para el conjunto de prueba o test que tenemos del 2024, con los siguientes pasos:
Partimos del dataframe de hechos o contrataciones reales del 2024,
df_fd1_v5_Real_ITE1_2024
.Realizamos las agrupaciones por
ID_BUILDING
yFM_COST_TYPE
con costes agregados por mes y año.Rellenamos los meses faltantes por pareja
Para cada pareja, reindexamos la serie entre su primer y último mes observado dentro de 2024, a frecuencia mensual, y rellenamos con 0, usando la función definida
reindex_group
.Para cada pareja, calculamos el modo (valor más frecuente) de variables de contexto (
COUNTRY_DEF
,ID_REGION_GRUPO
,TIPO_USO
,SUPPLIER_TYPE_MOD_2
,FM_RESPONSIBLE_MOD
) usando 2021-2023.Ensamblamos
df_test_fd1_v5_ITE1
Unimos la agregación mensual completa con el contexto deseado.
Añadimos columnas
YEAR
yMONTH
derivadas deFECHA
.Dejamos nombres claros y orden de columnas práctico.
Conciliamos parejas
ID_BUILDING
yFM_COST_TYPE
entre los 2 dataframesdf_test_fd1_v5_ITE1
vs.df_train_fd1_v5_ITE1
para identificar que se van a poder predecir el 100% de las series del 2024 o bien puede quedar alguna vacía fruto de un nuevoID_BUILDING
o un nuevoFM_COST_TYPE
que exista en 2024 pero no en 2021-2023.Realizamos una comprobacion rápida del
df_test_fd1_v5_ITE1
Forma del dataframe, rango temporal y muestra de filas para validar que todo está OK y sin duplicados.
# Definimos los nombres clave y los dejamos explícitos
COL_IDB, COL_FMCT = "ID_BUILDING", "FM_COST_TYPE"
COL_FECHA, COL_COST = "FECHA", "cost_float_mod"
CTX_COLS = ["COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"]
# Partimos del dataframe de hechos 2024 y añadimos FECHA a partir de YEAR/MONTH
# Creamos FECHA como el primer día de cada mes de 2024 (nos basamos en YEAR y MONTH del propio DF)
df_real_2024 = df_fd1_v5_Real_ITE1_2024.copy()
df_real_2024[COL_FECHA] = pd.to_datetime(
dict(year=df_real_2024["YEAR"], month=df_real_2024["MONTH"], day=1)
)
# Nos aseguramos de que el coste sea numérico
df_real_2024[COL_COST] = pd.to_numeric(df_real_2024[COL_COST], errors="coerce").fillna(0.0)
# Realizamos las agrupaciones por ID_BUILDING y FM_COST_TYPE con costes agregados por mes (2024)
# Agregamos el coste mensual por pareja y FECHA (sumatorio)
test_agg_2024 = (
df_real_2024
.groupby([COL_IDB, COL_FMCT, COL_FECHA], as_index=False)[COL_COST]
.sum()
)
# Rellenamos los meses faltantes por pareja y reindexamos con nuestra función reindex_group
# Preparamos el dataframe para que cada grupo tenga exactamente las columnas que espera reindex_group
test_agg_2024_for_reindex = test_agg_2024[[COL_IDB, COL_FMCT, COL_FECHA, COL_COST]].copy()
# Aplicamos nuestra función reindex_group por cada pareja (entre su primer y último mes observado en 2024)
# Nota: reindex_group(g) espera columnas: "FECHA", "cost_float_mod", "ID_BUILDING", "FM_COST_TYPE"
df_test_2024 = (
test_agg_2024_for_reindex
.groupby([COL_IDB, COL_FMCT], as_index=False, group_keys=False)
.apply(reindex_group)
.reset_index(drop=True)
)
# Para cada pareja, calculamos la moda de variables de contexto usando 2021–2023 (panel train)
# Definimos una función de moda simple (tomamos la categoría más frecuente por pareja en el train)
def _mode_safe(s: pd.Series):
vc = s.dropna().value_counts()
return vc.index[0] if len(vc) else np.nan
# Calculamos la moda por pareja a partir del panel de train (2021–2023)
ctx_modes = (
df_train_fd1_v5_ITE1[[COL_IDB, COL_FMCT] + CTX_COLS]
.groupby([COL_IDB, COL_FMCT], as_index=False)
.agg({c: _mode_safe for c in CTX_COLS})
)
# Ensamblamos df_test_fd1_v5_ITE1 uniendo la agregación mensual completa con el contexto (moda)
df_test_fd1_v5_ITE1 = df_test_2024.merge(ctx_modes, on=[COL_IDB, COL_FMCT], how="left")
# Añadimos columnas YEAR y MONTH derivadas de FECHA
df_test_fd1_v5_ITE1["YEAR"] = df_test_fd1_v5_ITE1[COL_FECHA].dt.year.astype(int)
df_test_fd1_v5_ITE1["MONTH"] = df_test_fd1_v5_ITE1[COL_FECHA].dt.month.astype(int)
# Dejamos nombres claros y orden de columnas práctico
ordered_cols = [COL_IDB, COL_FMCT, COL_FECHA, "YEAR", "MONTH", COL_COST] + CTX_COLS
ordered_cols += [c for c in df_test_fd1_v5_ITE1.columns if c not in ordered_cols]
df_test_fd1_v5_ITE1 = df_test_fd1_v5_ITE1[ordered_cols]
# Conciliamos parejas ID_BUILDING y FM_COST_TYPE entre TEST (2024) y TRAIN (2021–2023)
# Queremos identificar si podremos predecir el 100% o si hay parejas nuevas en 2024
pairs_test = df_test_fd1_v5_ITE1[[COL_IDB, COL_FMCT]].drop_duplicates()
pairs_train = df_train_fd1_v5_ITE1[[COL_IDB, COL_FMCT]].drop_duplicates()
# Parejas nuevas en 2024 (aparecen en test pero no en train)
pairs_new_in_test = (
pairs_test
.merge(pairs_train, on=[COL_IDB, COL_FMCT], how="left", indicator=True)
.query("_merge == 'left_only'")
.drop(columns="_merge")
)
# Parejas del train sin hechos en 2024 (están en train pero no aparecen en test)
pairs_missing_in_test = (
pairs_train
.merge(pairs_test, on=[COL_IDB, COL_FMCT], how="left", indicator=True)
.query("_merge == 'left_only'")
.drop(columns="_merge")
)
print("\n=== Conciliación de parejas train vs test (2024) ===")
print(f"Parejas en TRAIN (2021–2023): {len(pairs_train)}")
print(f"Parejas en TEST (2024) : {len(pairs_test)}")
print(f"Parejas NUEVAS en TEST (no estaban en TRAIN): {len(pairs_new_in_test)}")
if len(pairs_new_in_test):
print(pairs_new_in_test.head(10))
print(f"Parejas sin hechos en 2024 (están en TRAIN pero no en TEST): {len(pairs_missing_in_test)}")
if len(pairs_missing_in_test):
print(pairs_missing_in_test.head(10))
# Realizamos una comprobación rápida del df_test_fd1_v5_ITE1
# Verificamos forma, rango temporal y duplicados (por pareja y mes)
print("\n=== Validación rápida df_test_fd1_v5_ITE1 ===")
print("Shape:", df_test_fd1_v5_ITE1.shape)
print("Rango FECHA:", df_test_fd1_v5_ITE1[COL_FECHA].min().date(), "→", df_test_fd1_v5_ITE1[COL_FECHA].max().date())
dup_mask = df_test_fd1_v5_ITE1.duplicated(subset=[COL_IDB, COL_FMCT, COL_FECHA], keep=False)
print("Duplicados por (ID_BUILDING, FM_COST_TYPE, FECHA):", int(dup_mask.sum()))
print("\nMuestra (5 filas):")
print(df_test_fd1_v5_ITE1.head(5))
=== Conciliación de parejas train vs test (2024) === Parejas en TRAIN (2021–2023): 2429 Parejas en TEST (2024) : 2692 Parejas NUEVAS en TEST (no estaban en TRAIN): 461 ID_BUILDING FM_COST_TYPE 32 104 Eficiencia Energética 39 105 Eficiencia Energética 46 116 Eficiencia Energética 53 117 Eficiencia Energética 57 117 Obras 61 118 Eficiencia Energética 68 121 Eficiencia Energética 72 121 Obras 76 122 Eficiencia Energética 108 131 Eficiencia Energética Parejas sin hechos en 2024 (están en TRAIN pero no en TEST): 198 ID_BUILDING FM_COST_TYPE 0 2 Eficiencia Energética 24 57 Eficiencia Energética 25 57 Licencias 27 57 Mtto. Correctivo 28 57 Servicios Ctto. 29 57 Servicios Extra 30 57 Suministros 31 59 Licencias 33 59 Mtto. Correctivo 34 59 Obras === Validación rápida df_test_fd1_v5_ITE1 === Shape: (23750, 11) Rango FECHA: 2024-01-01 → 2024-12-01 Duplicados por (ID_BUILDING, FM_COST_TYPE, FECHA): 0 Muestra (5 filas): ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2024-12-01 2024 12 202.80 1 2 Mtto. Contratos 2024-01-01 2024 1 1874.00 2 2 Mtto. Contratos 2024-02-01 2024 2 0.00 3 2 Mtto. Contratos 2024-03-01 2024 3 0.00 4 2 Mtto. Contratos 2024-04-01 2024 4 175.58 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD 0 España 2 Oficinas INTERNO Licencias 1 España 2 Oficinas EXTERNO Mantenimiento 2 España 2 Oficinas EXTERNO Mantenimiento 3 España 2 Oficinas EXTERNO Mantenimiento 4 España 2 Oficinas EXTERNO Mantenimiento
Contrastamos resultados test vs. train
print("Shape df_train_fd1_v5_ITE1:", df_train_fd1_v5_ITE1.shape)
print("Rango FECHA:", df_train_fd1_v5_ITE1[COL_FECHA].min().date(), "→", df_train_fd1_v5_ITE1[COL_FECHA].max().date())
print("\nMuestra (5 filas):")
print(df_train_fd1_v5_ITE1.head(5))
Shape df_train_fd1_v5_ITE1: (65466, 11) Rango FECHA: 2021-01-01 → 2023-12-01 Muestra (5 filas): ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Eficiencia Energética 2021-12-01 2021 12 0.00 1 2 Licencias 2021-01-01 2021 1 1145.46 2 2 Licencias 2021-02-01 2021 2 55.95 3 2 Licencias 2021-03-01 2021 3 0.00 4 2 Licencias 2021-04-01 2021 4 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas INTERNO 1 España 2 Oficinas INTERNO 2 España 2 Oficinas INTERNO 3 España 2 Oficinas INTERNO 4 España 2 Oficinas INTERNO FM_RESPONSIBLE_MOD 0 Eficiencia Energética 1 Licencias 2 Licencias 3 Licencias 4 Licencias
El resultado es coherente a nivel estructura, por lo que podemos asumir que el dataset test ha quedado listo para ser segmentado según los modelos sugeridos tal y como se ha realizado con train.
Propagamos el modelo sugerido del train al test y separamos el test en dataframes por grupo de modelo¶
Segregamos el dataframe df_test_fd1_v5_ITE1
según el criterio de segregación resultado de aplicar el diagnóstico para las parejas que se especifica en la variable model_suggested
del dataframe df_result_train_fd1_v5_ITE1
.
Dejamos aparte las parejas nuevas del test que no están en el train y por lo tanto no tienen modelo sugerido.
# === TEST → Asignamos modelo sugerido desde el train y separamos en grupos ===
# Nos quedamos del diagnóstico del train con lo esencial para mapear al test
cols_diag = ["ID_BUILDING","FM_COST_TYPE","diagnosis","model_suggested"]
diag_short_train = df_result_train_fd1_v5_ITE1[cols_diag].drop_duplicates()
# Unimos el test con el diagnóstico del train (por pareja) para traer el modelo_sugerido
# - Las parejas nuevas en 2024 quedarán con NaN en 'model_suggested'
# (puesto no existían en 2021–2023 por lo que no van a tener predicción)
df_test_model = df_test_fd1_v5_ITE1.merge(
diag_short_train, on=["ID_BUILDING","FM_COST_TYPE"], how="left"
)
# Normalizamos el modelo sugerido a un sufijo de grupo (ETS, SARIMA, ARIMA, POOLING, CROSTON, HOLT, ROBUSTO, OTROS)
def normalizar_modelo(model_suggested: str) -> str:
if not isinstance(model_suggested, str):
return "SIN_MODELO" # parejas nuevas en 2024 (no estaban en train)
s = model_suggested.lower()
if "pool" in s or "naive" in s:
return "POOLING"
if "croston" in s or "sba" in s:
return "CROSTON"
if "sarima" in s:
return "SARIMA"
if "holt" in s:
return "HOLT"
if "arima" in s:
return "ARIMA"
if "robusto" in s or "winsor" in s or "tbats" in s:
return "ROBUSTO"
if "ets" in s or "holt-winters" in s:
return "ETS"
return "OTROS"
# Creamos la columna de grupo de modelo para el test
df_test_model["model_group"] = df_test_model["model_suggested"].map(normalizar_modelo)
# Separamos el test en sub-dataframes por 'model_group' (mismos sufijos que en train)
base_name_test = "df_test_fd1_v5_ITE1"
grupos_test = sorted(df_test_model["model_group"].dropna().unique())
created_test = {}
for g in grupos_test:
# Filtramos el panel test para cada grupo de modelo
df_g = df_test_model[df_test_model["model_group"] == g].copy()
# Construimos el nombre dinámico (ej. df_test_fd1_v5_ITE1_ETS)
var_name = f"{base_name_test}_{g}"
# Creamos el dataframe en el entorno global con ese nombre
globals()[var_name] = df_g
# Guardamos referencia para el resumen
created_test[g] = (var_name, df_g.shape)
# Mostramos un resumen para validar
print("Sub-dataframes de TEST creados por modelo:")
for g,(name,shape) in created_test.items():
# Contamos las parejas únicas en cada sub-bloque
n_pairs = globals()[name][["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0]
print(f" - {name:32s}: {shape} | parejas únicas = {n_pairs}")
# Visualizamos las parejas NUEVAS (SIN_MODELO) para decidir política (p.ej. POOLING/NAIVE o marcar para revisión)
if "SIN_MODELO" in created_test:
name_new, shape_new = created_test["SIN_MODELO"]
nuevas_pairs = globals()[name_new][["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
print(f"\n[AVISO] Parejas nuevas en 2024 sin modelo del train (SIN_MODELO): {len(nuevas_pairs)}")
print(nuevas_pairs.head(10))
# 7) (Opcional) Si queremos alinear exactamente la cobertura del test con el train,
# filtramos el test para solo parejas presentes en el train:
# df_test_model_in_train = df_test_model.merge(
# df_train_fd1_v5_ITE1[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates(),
# on=["ID_BUILDING","FM_COST_TYPE"], how="inner"
# )
# (Entonces repetiríamos la separación por 'model_group' con este subconjunto)
Sub-dataframes de TEST creados por modelo: - df_test_fd1_v5_ITE1_ARIMA : (5835, 14) | parejas únicas = 491 - df_test_fd1_v5_ITE1_CROSTON : (4680, 14) | parejas únicas = 472 - df_test_fd1_v5_ITE1_HOLT : (368, 14) | parejas únicas = 31 - df_test_fd1_v5_ITE1_POOLING : (4881, 14) | parejas únicas = 696 - df_test_fd1_v5_ITE1_ROBUSTO : (704, 14) | parejas únicas = 60 - df_test_fd1_v5_ITE1_SARIMA : (5648, 14) | parejas únicas = 481 - df_test_fd1_v5_ITE1_SIN_MODELO : (1634, 14) | parejas únicas = 461 [AVISO] Parejas nuevas en 2024 sin modelo del train (SIN_MODELO): 461 ID_BUILDING FM_COST_TYPE 289 104 Eficiencia Energética 359 105 Eficiencia Energética 428 116 Eficiencia Energética 486 117 Eficiencia Energética 520 117 Obras 556 118 Eficiencia Energética 621 121 Eficiencia Energética 656 121 Obras 690 122 Eficiencia Energética 1026 131 Eficiencia Energética
Interpretación práctica
La relación entre filas y parejas únicas en df_test_fd1_v5_ITE1_ARIMA
nos indica cuántos registros tiene cada serie en el test 2024.
Filas totales: 5 835
Parejas únicas: 491
Si dividimos:
5835 / 491 ≈ 11.88
Esto significa que, en promedio, cada pareja ID_BUILDING–FM_COST_TYPE tiene unas 12 filas en el dataframe, que coincide con lo esperado: un registro por mes de 2024 (enero-diciembre).
Cada pareja genera una serie mensual a lo largo de 2024.
Si todas las parejas tuvieran los 12 meses completos, deberíamos ver exactamente 491 x 12 = 5892 registros o filas de la pareja con coste.
Como tenemos 5 835 filas, es decir, faltan unas 57 observaciones respecto al ideal. Esto se debe a:
Que en algunas parejas su primer o último registro en 2024 no cubre los 12 meses completos (por ejemplo, empiezan en marzo o terminan en septiembre).
Que la reindexación con
reindex_group
se hizo entre el primer y último mes observado de 2024 de cada pareja (no necesariamente todo enero-diciembre), tal y como definimos en el código.
Realizamos la misma revisión para todos los grupos del conjunto train.
# Filas totales y parejas únicas en el train
n_rows_train = df_train_fd1_v5_ITE1.shape[0]
n_pairs_train = df_train_fd1_v5_ITE1[[COL_IDB, COL_FMCT]].drop_duplicates().shape[0]
print("Filas totales en TRAIN:", n_rows_train)
print("Parejas únicas en TRAIN:", n_pairs_train)
print("Promedio de meses por pareja:", round(n_rows_train / n_pairs_train, 2))
# Distribución exacta: nº de meses por pareja
meses_por_pareja = (
df_train_fd1_v5_ITE1
.groupby([COL_IDB, COL_FMCT])[COL_FECHA]
.nunique()
.reset_index(name="n_meses")
)
print("\nResumen de nº de meses por pareja en el TRAIN:")
print(meses_por_pareja["n_meses"].describe())
# Opcional: ver cuántas parejas tienen exactamente 36 meses
n_full = (meses_por_pareja["n_meses"] == 36).sum()
print(f"\nParejas con los 36 meses completos: {n_full} de {n_pairs_train}")
Filas totales en TRAIN: 65466 Parejas únicas en TRAIN: 2429 Promedio de meses por pareja: 26.95 Resumen de nº de meses por pareja en el TRAIN: count 2429.000000 mean 26.951832 std 11.864442 min 1.000000 25% 19.000000 50% 33.000000 75% 36.000000 max 36.000000 Name: n_meses, dtype: float64 Parejas con los 36 meses completos: 925 de 2429
Guardamos en el Drive el conjunto entero test con el modelo sugerido y tambien los sub-dataframe que se han generado para agrupar las parejas según los modelos sugeridos del train 2021-2023.¶
Generamos un excel individual por modelo sugerido y un csv individual separado con ";".
# === GUARDAMOS TEST COMPLETO + SUB-DATAFRAMES POR GRUPO (Excel + CSV con ';') ===
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
os.makedirs(OUT_DIR, exist_ok=True)
# (Preventivo) Función simple para quitar tz si alguna columna datetime la trae con zona horaria
def _sanitize_for_excel(df: pd.DataFrame) -> pd.DataFrame:
df2 = df.copy()
for c in df2.columns:
if is_datetime64tz_dtype(df2[c]):
try:
df2[c] = df2[c].dt.tz_convert(None)
except Exception:
df2[c] = df2[c].dt.tz_localize(None)
return df2
# Guardamos el TEST completo con el modelo sugerido
# Nos aseguramos de que exista df_test_model (test + diagnosis/model_suggested + model_group)
df_master_test = df_test_model.copy()
# Rutas del master
csv_master_path = os.path.join(OUT_DIR, "df_test_fd1_v5_ITE1__master.csv")
xlsx_master_path = os.path.join(OUT_DIR, "df_test_fd1_v5_ITE1__master.xlsx")
# Guardamos CSV (separador ';')
# Comentario: usamos ';' para compatibilidad con locales que usan coma decimal
df_master_test.to_csv(csv_master_path, index=False, sep=';')
# Guardamos Excel (una hoja)
try:
_sanitize_for_excel(df_master_test).to_excel(xlsx_master_path, index=False)
except Exception as e:
print(f"[AVISO] No se pudo guardar el Excel master: {type(e).__name__}: {e}")
print(f"[OK] Guardado TEST master:")
print(" - CSV (;) :", csv_master_path)
print(" - Excel :", xlsx_master_path)
# Guardamos cada sub-dataframe de TEST por grupo (created_test)
# created_test: dict con {grupo: (var_name, shape)} creado previamente
for g, (var_name, shape) in created_test.items():
df_g = globals()[var_name].copy()
# Definimos rutas por grupo
csv_path = os.path.join(OUT_DIR, f"{var_name}.csv")
xlsx_path = os.path.join(OUT_DIR, f"{var_name}.xlsx")
# Guardamos CSV con separador ';'
df_g.to_csv(csv_path, index=False, sep=';')
# Guardamos Excel individual
try:
_sanitize_for_excel(df_g).to_excel(xlsx_path, index=False)
except Exception as e:
print(f"[AVISO] No se pudo guardar {var_name} en Excel: {type(e).__name__}: {e}")
# Mostramos confirmación
print(f"[OK] Guardado {var_name} | filas: {shape[0]} | columnas: {shape[1]}")
print(" - CSV (;) :", csv_path)
print(" - Excel :", xlsx_path)
[OK] Guardado TEST master: - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1__master.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1__master.xlsx [OK] Guardado df_test_fd1_v5_ITE1_ARIMA | filas: 5835 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_ARIMA.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_ARIMA.xlsx [OK] Guardado df_test_fd1_v5_ITE1_CROSTON | filas: 4680 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_CROSTON.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_CROSTON.xlsx [OK] Guardado df_test_fd1_v5_ITE1_HOLT | filas: 368 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_HOLT.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_HOLT.xlsx [OK] Guardado df_test_fd1_v5_ITE1_POOLING | filas: 4881 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_POOLING.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_POOLING.xlsx [OK] Guardado df_test_fd1_v5_ITE1_ROBUSTO | filas: 704 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_ROBUSTO.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_ROBUSTO.xlsx [OK] Guardado df_test_fd1_v5_ITE1_SARIMA | filas: 5648 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_SARIMA.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_SARIMA.xlsx [OK] Guardado df_test_fd1_v5_ITE1_SIN_MODELO | filas: 1634 | columnas: 14 - CSV (;) : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_SIN_MODELO.csv - Excel : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_SIN_MODELO.xlsx
Generamos el notebook en HTML¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_Pre_Modelizacion_ST.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 1 image(s). [NbConvertApp] Writing 1530363 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_Pre_Modelizacion_ST.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_Pre_Modelizacion_ST.html Existe: True
Modelización de las series temporales para los conjuntos train de la Iteración 1.¶
Definimos funciones que reutilizaremos en todas las modelizaciones:¶
Funciones de metricas de validación¶
def mae(y_true, y_pred):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return np.mean(np.abs(y_true - y_pred))
def mape(y_true, y_pred):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
denom = np.where(y_true == 0, 1.0, y_true)
return np.mean(np.abs((y_true - y_pred) / denom)) * 100.0
def wape(y_true, y_pred):
denom = np.sum(np.abs(y_true))
return np.nan if denom == 0 else np.sum(np.abs(y_true - y_pred)) / denom
def smape(y_true, y_pred):
denom = (np.abs(y_true) + np.abs(y_pred))
denom = np.where(denom == 0, 1, denom)
return 100 * np.mean(2*np.abs(y_pred - y_true)/denom)
def mase(y_train, y_true, y_pred, m=1):
"""
MASE respecto a Naive(m).
m=1 -> naive simple; si prefieres estacional, usa m=12 para mensual.
"""
y_train = np.asarray(y_train, dtype=float)
if len(y_train) <= m:
m = 1 # salvaguarda
d = np.mean(np.abs(y_train[m:] - y_train[:-m]))
d = 1.0 if (d is None or d == 0 or np.isnan(d)) else d
return float(np.mean(np.abs(np.asarray(y_true) - np.asarray(y_pred))) / d)
# Definimos MASE sin salvaguardas: cuando no hay historia suficiente o d==0/NaN → NaN
def mase_no_fallback(y_train, y_true, y_pred, m=1):
# Medimos MASE respecto a Naive(m); p.ej. m=1 naive simple, m=12 naive estacional mensual
import numpy as np
y_train = np.asarray(y_train, dtype=float)
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
if len(y_train) <= m:
return np.nan
d = np.mean(np.abs(y_train[m:] - y_train[:-m]))
if not np.isfinite(d) or d == 0:
return np.nan
return float(np.mean(np.abs(y_true - y_pred)) / d)
def mase12(y_train, y_true, y_pred):
# Atajo para MASE estacional mensual, sin fallback
return mase_no_fallback(y_train, y_true, y_pred, m=12)
Definimos funciones de utilidades¶
def ensure_ts(df_pair):
"""Devuelve la serie mensual indexada por fecha, MS, float, ordenada."""
s = df_pair[["FECHA", "cost_float_mod"]].copy()
s["FECHA"] = pd.to_datetime(s["FECHA"])
s = s.sort_values("FECHA").set_index("FECHA")["cost_float_mod"].asfreq("MS")
# Relleno mínimo si hay NaN sueltos (no debería, pero por seguridad)
if s.isna().any():
s = s.fillna(0.0)
return s.astype(float)
# Helper: obtenemos la serie mensual continua por pareja usando ensure_ts
def pair_series(df, pair):
b, t = pair
df_pair = df[(df["ID_BUILDING"] == b) & (df["FM_COST_TYPE"] == t)][["FECHA", "cost_float_mod"]].copy()
return ensure_ts(df_pair) # devuelve serie mensual indexada por fecha (MS), float, ordenada
MODELIZACIÓN DE SERIES CON ETS/ARIMA¶
Cargamos el dataframe train para modelizar: df_train_fd1_v5_ITE1_ARIMA
¶
df_train_fd1_v5_ITE1_ARIMA
contiene todas las series diagnosticadas en el train (2021-2023) como ARIMA.
Son aquellas parejas ID_BUILDING
- FM_COST_TYPE
que en el diagnóstico mostraban una estructura de tipo tendencia sin fuerte estacionalidad, o clasificadas como “ETS_simple|ARIMA_basico” (según cómo normalizamos el campo model_suggested
).
El criterio de clasificación ETS/ARIMA de las parejas se establece después de descartar que la pareja analizada con kpis se ajuste a otros modelos sugeridos. Esto indica que es un cajón de sastre en la que vamos a tener que afinar más para acabar de decir el mejor modelo.
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- OPCIÓN 1: Cargar desde CSV (con separador ';') ---
csv_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_ARIMA.csv"
df_train_fd1_v5_ITE1_ARIMA = pd.read_csv(csv_path, sep=';')
# --- OPCIÓN 2: Cargar desde Excel ---
# xlsx_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_ARIMA.xlsx"
# df_train_fd1_v5_ITE1_ARIMA = pd.read_excel(xlsx_path)
# Visualizamos forma y primeras filas para validar
print("Shape:", df_train_fd1_v5_ITE1_ARIMA.shape)
print(df_train_fd1_v5_ITE1_ARIMA.head())
Shape: (17188, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Suministros 2021-01-01 2021 1 6037.69 1 2 Suministros 2021-02-01 2021 2 0.00 2 2 Suministros 2021-03-01 2021 3 9889.47 3 2 Suministros 2021-04-01 2021 4 4908.41 4 2 Suministros 2021-05-01 2021 5 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas EXTERNO 1 España 2 Oficinas EXTERNO 2 España 2 Oficinas EXTERNO 3 España 2 Oficinas EXTERNO 4 España 2 Oficinas EXTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 1 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 2 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 3 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 4 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA
Cargamos el dataframe df_test_fd1_v5_ITE1_ARIMA
que contiene las mismas parejas ID_BUILDING
- FM_COST_TYPE
del conjunto entrenamiento.
# Ruta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- Conjunto TEST para ITE1 (año 2024) ---
csv_test_path = f"{OUT_DIR}/df_test_fd1_v5_ITE1_ARIMA.csv"
df_test_fd1_v5_ITE1_ARIMA = pd.read_csv(csv_test_path, sep=';')
print("Shape TEST:", df_test_fd1_v5_ITE1_ARIMA.shape)
print(df_test_fd1_v5_ITE1_ARIMA.head())
Shape TEST: (5835, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Suministros 2024-01-01 2024 1 6735.70 1 2 Suministros 2024-02-01 2024 2 5385.96 2 2 Suministros 2024-03-01 2024 3 4701.77 3 2 Suministros 2024-04-01 2024 4 3183.83 4 2 Suministros 2024-05-01 2024 5 759.71 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas EXTERNO 1 España 2 Oficinas EXTERNO 2 España 2 Oficinas EXTERNO 3 España 2 Oficinas EXTERNO 4 España 2 Oficinas EXTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 1 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 2 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 3 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA 4 Eficiencia Energética estable ETS_simple|ARIMA_basico ARIMA
Ahora ya tenemos confirmados los dos conjuntos:
Train (df_train_fd1_v5_ITE1_ARIMA) -> 2021-2023
Test (df_test_fd1_v5_ITE1_ARIMA) -> 2024
Y ambos están alineados en las mismas parejas (ID_BUILDING
, FM_COST_TYPE
).
Eso nos permite trabajar en un flujo clásico de entrenamiento y validación:
Vamos a seleccionar una serie concreta (ej.
ID_BUILDING
= 9,FM_COST_TYPE
= "Suministros").Prepararemos la serie temporal con el train (indexada por
FECHA
).Entrenaremos un modelo ETS o ARIMA usando solo los datos 2021-2023.
Generaremos predicciones para 2024 con el mismo horizonte que el test.
Compararemos predicciones vs test con métricas (RMSE, MAE, MAPE).
De esta forma validamos si la recomendación “ETS_simple | ARIMA_básico” que aparece en tu dataset es acertada para esa serie.
Necesitamos primero alinear los conjuntos para que solo se entrenen las parejas (ID_BUILDING
, FM_COST_TYPE
) que existen en train y test.
Así evitamos dedicar tiempo de cálculo a series que no se validarán ni tendrán continuidad.
Conciliamos ambos conjuntos para entrenar solo las parejas del conjunto test a predecir¶
# Obtenemos las claves únicas (ID_BUILDING, FM_COST_TYPE) en train y test
train_pairs = df_train_fd1_v5_ITE1_ARIMA[["ID_BUILDING", "FM_COST_TYPE"]].drop_duplicates()
test_pairs = df_test_fd1_v5_ITE1_ARIMA[["ID_BUILDING", "FM_COST_TYPE"]].drop_duplicates()
# Nos quedamos con la intersección
valid_pairs = pd.merge(train_pairs, test_pairs, on=["ID_BUILDING", "FM_COST_TYPE"], how="inner")
print("Series en train:", len(train_pairs))
print("Series en test:", len(test_pairs))
print("Series comunes (válidas):", len(valid_pairs))
# Filtramos el dataset de train para quedarnos solo con esas parejas
df_train_fd1_v5_ITE1_ARIMA_filtered = df_train_fd1_v5_ITE1_ARIMA.merge(
valid_pairs, on=["ID_BUILDING", "FM_COST_TYPE"], how="inner"
)
print("Shape train original:", df_train_fd1_v5_ITE1_ARIMA.shape)
print("Shape train filtrado:", df_train_fd1_v5_ITE1_ARIMA_filtered.shape)
Series en train: 500 Series en test: 491 Series comunes (válidas): 491 Shape train original: (17188, 14) Shape train filtrado: (16885, 14)
Validamos conciliación¶
Aplicamos la misma operación al test, para verificar que la dimensión del dataframe no varia.
# Filtramos también el test para quedarnos solo con las parejas válidas
df_test_fd1_v5_ITE1_ARIMA_filtered = df_test_fd1_v5_ITE1_ARIMA.merge(
valid_pairs, on=["ID_BUILDING", "FM_COST_TYPE"], how="inner"
)
print("Shape test original:", df_test_fd1_v5_ITE1_ARIMA.shape)
print("Shape test filtrado:", df_test_fd1_v5_ITE1_ARIMA_filtered.shape)
Shape test original: (5835, 14) Shape test filtrado: (5835, 14)
Aunque podríamos afinar con que modelo realizar el entrenar usando acf1 y acf12, vamos a ser prácticos y como los dos conjuntos ya están alineados (491 parejas válidas), paseremos a entrenar y validar ETS vs ARIMA por pareja (2021-2023 para predecir el 2024 y nos quedaremos con el mejor según el AIC de train y MAE/MAPE en test.
Definimos constantes y paraámetros¶
# -------------------------------------------------------------------
# Definimos constantes y parámetros
# -------------------------------------------------------------------
H = 12 # horizonte de predicción mensual (2024 completo)
PAIR_COLS = ["ID_BUILDING", "FM_COST_TYPE"]
TARGET = "cost_float_mod"
DATECOL = "FECHA"
Definimos funciones ETS y ARIMA¶
def fit_ets_variants(y):
"""
Variantes ETS ligeras:
- SES (simple)
- Holt aditivo (tendencia)
- Holt aditivo amortiguado (tendencia amortiguada)
Selección por AIC aproximado.
"""
candidates = []
# SES
try:
m_ses = SimpleExpSmoothing(y, initialization_method="estimated").fit(optimized=True)
aic_ses = -2*m_ses.model.loglikelihood + 2*m_ses.params.shape[0]
candidates.append(("ETS_SES", m_ses, aic_ses))
except Exception:
pass
# Holt (tendencia aditiva)
try:
m_holt = ExponentialSmoothing(y, trend="add", seasonal=None, initialization_method="estimated").fit(optimized=True)
aic_holt = -2*m_holt.model.loglikelihood + 2*m_holt.params.shape[0]
candidates.append(("ETS_HOLT", m_holt, aic_holt))
except Exception:
pass
# Holt amortiguado
try:
m_holt_d = ExponentialSmoothing(
y, trend="add", damped_trend=True, seasonal=None, initialization_method="estimated"
).fit(optimized=True)
aic_holt_d = -2*m_holt_d.model.loglikelihood + 2*m_holt_d.params.shape[0]
candidates.append(("ETS_HOLT_DAMPED", m_holt_d, aic_holt_d))
except Exception:
pass
if not candidates:
return None, None, np.inf
best = sorted(candidates, key=lambda x: x[2])[0]
return best # (name, fitted_model, aic)
def fit_arima_basic_grid(y):
"""
'ARIMA_basico': grid muy acotado para no disparar tiempos:
(p,d,q) en {(0,1,1), (1,1,0), (1,1,1), (1,0,0)}
Seleccionamos por AIC.
"""
grid = [(0,1,1), (1,1,0), (1,1,1), (1,0,0)]
candidates = []
for order in grid:
try:
model = ARIMA(y, order=order)
res = model.fit(method_kwargs={"warn_convergence": False})
candidates.append((f"ARIMA{order}", res, res.aic))
except Exception:
continue
if not candidates:
return None, None, np.inf
best = sorted(candidates, key=lambda x: x[2])[0]
return best # (name, fitted_model, aic)
def forecast_match_index(fitted, start, periods=H):
"""Genera un forecast de 'periods' pasos y devuelve Serie con índice mensual desde 'start'."""
fc = fitted.forecast(steps=periods)
# Aseguramos índice temporal correcto:
idx = pd.date_range(start=start, periods=periods, freq="MS")
fc = pd.Series(np.asarray(fc, dtype=float), index=idx, name="forecast")
# Truncado de negativos (en costes no tiene sentido)
fc = fc.clip(lower=0.0)
return fc
Entrenamos con ETS y sus variantes y con ARIMA¶
train_df = df_train_fd1_v5_ITE1_ARIMA_filtered.copy()
test_df = df_test_fd1_v5_ITE1_ARIMA_filtered.copy()
# -------------------------------------------------------------------
# Loop por parejas -> entrenar ETS y ARIMA sobre 2021-2023, predecir 2024 y evaluar
# -------------------------------------------------------------------
results = []
failed_pairs = []
# Índice esperado del test (12 meses 2024)
test_months = pd.date_range("2024-01-01", periods=H, freq="MS")
# Listado de parejas válidas
pairs = train_df[PAIR_COLS].drop_duplicates().sort_values(PAIR_COLS).values.tolist()
for bid, ctype in pairs:
tr_pair = train_df[(train_df["ID_BUILDING"] == bid) & (train_df["FM_COST_TYPE"] == ctype)]
te_pair = test_df [(test_df ["ID_BUILDING"] == bid) & (test_df ["FM_COST_TYPE"] == ctype)]
try:
y_train = ensure_ts(tr_pair)
y_test = ensure_ts(te_pair).reindex(test_months)
# Si la serie de train es todo ceros o muy corta, saltamos (se cubrirá con pooling en otro pipeline)
if (y_train.fillna(0).sum() == 0) or (y_train.dropna().shape[0] < 8):
failed_pairs.append((bid, ctype, "serie_train_degenerate"))
continue
# Ajustes
ets_name, ets_fit, ets_aic = fit_ets_variants(y_train)
ar_name, ar_fit, ar_aic = fit_arima_basic_grid(y_train)
# Selección por AIC (si falta alguno, toma el otro)
best_name, best_fit, best_aic, second_name, second_fit = None, None, np.inf, None, None
if ets_fit is None and ar_fit is None:
failed_pairs.append((bid, ctype, "sin_modelos_validos"))
continue
elif ets_fit is None:
best_name, best_fit, best_aic = ar_name, ar_fit, ar_aic
elif ar_fit is None:
best_name, best_fit, best_aic = ets_name, ets_fit, ets_aic
else:
if ets_aic <= ar_aic:
best_name, best_fit, best_aic = ets_name, ets_fit, ets_aic
second_name, second_fit = ar_name, ar_fit
else:
best_name, best_fit, best_aic = ar_name, ar_fit, ar_aic
second_name, second_fit = ets_name, ets_fit
# Obtenemos las previsiones o forecasts 2024
start_2024 = pd.Timestamp("2024-01-01")
yhat_best = forecast_match_index(best_fit, start_2024, periods=H)
# Calculamos métricas
m_mae = mae(y_test.values, yhat_best.values)
m_mape = mape(y_test.values, yhat_best.values)
# También medimos el segundo para diagnóstico
sec_mae = sec_mape = np.nan
if second_fit is not None:
yhat_sec = forecast_match_index(second_fit, start_2024, periods=H)
sec_mae = mae(y_test.values, yhat_sec.values)
sec_mape = mape(y_test.values, yhat_sec.values)
results.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"best_model": best_name,
"best_aic": best_aic,
"MAE_2024": m_mae,
"MAPE_2024": m_mape,
"second_model": second_name,
"sec_MAE_2024": sec_mae,
"sec_MAPE_2024": sec_mape
})
except Exception as e:
failed_pairs.append((bid, ctype, str(e)))
continue
res_df = pd.DataFrame(results)
print("Modelos evaluados:", res_df.shape[0], "de", len(pairs))
print(res_df.groupby("best_model").size().sort_values(ascending=False))
print("\nMAE global (media):", res_df["MAE_2024"].mean().round(2))
print("MAPE global (media):", res_df["MAPE_2024"].mean().round(2), "%")
# TOP 10 mejores y peores (por MAE)
print("\nTOP 10 mejores (MAE):")
print(res_df.nsmallest(10, "MAE_2024")[["ID_BUILDING","FM_COST_TYPE","best_model","MAE_2024","MAPE_2024"]])
print("\nTOP 10 peores (MAE):")
print(res_df.nlargest(10, "MAE_2024")[["ID_BUILDING","FM_COST_TYPE","best_model","MAE_2024","MAPE_2024"]])
# Guardamos resultados
# OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# out_csv = f"{OUT_DIR}/res_ITE1_ARIMA_vs_ETS_por_pareja.csv"
# res_df.to_csv(out_csv, index=False, sep=";")
# print("\nResultados guardados en:", out_csv)
if failed_pairs:
print("\nParejas omitidas / con error (primeras 20):", failed_pairs[:20])
Modelos evaluados: 491 de 491 best_model ARIMA(0, 1, 1) 378 ARIMA(1, 1, 0) 59 ARIMA(1, 1, 1) 53 ARIMA(1, 0, 0) 1 dtype: int64 MAE global (media): 684.97 MAPE global (media): 2692.53 % TOP 10 mejores (MAE): ID_BUILDING FM_COST_TYPE best_model MAE_2024 \ 189 1170 Mtto. Contratos ARIMA(1, 1, 0) 0.000000 472 1001210 Eficiencia Energética ARIMA(1, 0, 0) 0.000005 357 1000608 Mtto. Contratos ARIMA(1, 1, 1) 0.006274 277 1000310 Servicios Ctto. ARIMA(0, 1, 1) 3.960000 160 1089 Eficiencia Energética ARIMA(1, 1, 0) 4.875000 175 1100 Eficiencia Energética ARIMA(1, 1, 0) 4.881818 151 1084 Eficiencia Energética ARIMA(1, 1, 0) 5.229545 157 1087 Eficiencia Energética ARIMA(1, 1, 0) 5.229545 122 1024 Suministros ARIMA(0, 1, 1) 9.047618 353 1000598 Suministros ARIMA(0, 1, 1) 9.189820 MAPE_2024 189 0.000000e+00 472 1.362106e-07 357 3.450481e-03 277 2.436923e+00 160 1.890134e+00 175 1.892778e+00 151 1.776340e+00 157 1.776340e+00 122 2.438515e+01 353 3.118761e+01 TOP 10 peores (MAE): ID_BUILDING FM_COST_TYPE best_model MAE_2024 \ 415 1001070 Suministros ARIMA(0, 1, 1) 13380.836462 129 1065 Suministros ARIMA(0, 1, 1) 9932.522655 116 935 Suministros ARIMA(0, 1, 1) 8535.036500 454 1001173 Suministros ARIMA(0, 1, 1) 7859.447983 247 1296 Suministros ARIMA(1, 1, 0) 7156.328499 257 1572 Suministros ARIMA(0, 1, 1) 6802.281240 0 2 Suministros ARIMA(0, 1, 1) 5779.024000 4 18 Obras ARIMA(0, 1, 1) 5099.201500 107 595 Suministros ARIMA(0, 1, 1) 4355.257475 413 1001070 Mtto. Correctivo ARIMA(0, 1, 1) 4278.926669 MAPE_2024 415 691683.411504 129 82.705817 116 64.981087 454 73.627427 247 26.633430 257 83.781072 0 1016.270921 4 2839.057973 107 99.601470 413 214.060831
Interpretación:
Todos los ganadores son ARIMA (mayoría (0,1,1)). Normal: con 36 puntos (2021-2023) y sin estacionalidad explícita, ARIMA(0,1,1) suele ser “workhorse”.
MAE medio ≈ 685 es razonable según escala, pero el MAPE medio ≈ 2.692%·10³ es una alarma falsa: el MAPE explota cuando hay meses con valor real ≈ 0 (muy habitual en OPEX con meses sin gasto o abonos). En tus “Top peores” el MAPE se dispara por esta razón.
Top mejores con MAE ~0 indican parejas con gasto casi nulo en 2024; no significa que el modelo sea “perfecto”, sino que el objetivo era prácticamente 0.
Ajustes a realizar:
Añadimos métricas sMAPE y WAPE que evitan la patología del MAPE con los ceros, pero seguimos manteniendo MAE.
Incorporamos las métricas sMAPE/WAPE/MASE y adaptamos también el dataframe resultante para que guarde el pronóstico mensual por pareja realizado y añada el real.
MASE1 compara contra naive simple; MASE12 contra naive estacional.
# Copiamos train/test filtrados para ETS/ARIMA
train_df = df_train_fd1_v5_ITE1_ARIMA_filtered.copy()
test_df = df_test_fd1_v5_ITE1_ARIMA_filtered.copy()
# -------------------------------------------------------------------
# Loop por parejas: entrenar ETS/ARIMA (2021-2023) -> predecir 2024 -> evaluar
# Además, guardamos el pronóstico mensual del mejor modelo.
# -------------------------------------------------------------------
results = []
failed_pairs = []
forecasts_rows = [] # para guardar pronóstico mensual por pareja
test_months = pd.date_range("2024-01-01", periods=H, freq="MS")
pairs = train_df[PAIR_COLS].drop_duplicates().sort_values(PAIR_COLS).values.tolist()
for bid, ctype in pairs:
tr_pair = train_df[(train_df["ID_BUILDING"] == bid) & (train_df["FM_COST_TYPE"] == ctype)]
te_pair = test_df [(test_df ["ID_BUILDING"] == bid) & (test_df ["FM_COST_TYPE"] == ctype)]
try:
y_train = ensure_ts(tr_pair)
y_test = ensure_ts(te_pair).reindex(test_months)
# Series degeneradas
if (y_train.fillna(0).sum() == 0) or (y_train.dropna().shape[0] < 8):
failed_pairs.append((bid, ctype, "serie_train_degenerate"))
continue
# Ajustes ETS (3 variantes) y ARIMA (rejilla)
ets_name, ets_fit, ets_aic = fit_ets_variants(y_train)
ar_name, ar_fit, ar_aic = fit_arima_basic_grid(y_train)
# Selección por AIC
best_name, best_fit, best_aic, second_name, second_fit = None, None, np.inf, None, None
if ets_fit is None and ar_fit is None:
failed_pairs.append((bid, ctype, "sin_modelos_validos"))
continue
elif ets_fit is None:
best_name, best_fit, best_aic = ar_name, ar_fit, ar_aic
elif ar_fit is None:
best_name, best_fit, best_aic = ets_name, ets_fit, ets_aic
else:
if ets_aic <= ar_aic:
best_name, best_fit, best_aic = ets_name, ets_fit, ets_aic
second_name, second_fit = ar_name, ar_fit
else:
best_name, best_fit, best_aic = ar_name, ar_fit, ar_aic
second_name, second_fit = ets_name, ets_fit
# Pronóstico 2024 (mejor modelo)
start_2024 = pd.Timestamp("2024-01-01")
yhat_best = forecast_match_index(best_fit, start_2024, periods=H)
# Métricas del mejor modelo
m_mae = mae (y_test.values, yhat_best.values)
m_mape = mape (y_test.values, yhat_best.values)
m_smape = smape(y_test.values, yhat_best.values)
m_wape = wape (y_test.values, yhat_best.values)
m_mase1 = mase_no_fallback(y_train.values, y_test.values, yhat_best.values, m=1) # naive simple
m_mase12 = mase12 (y_train.values, y_test.values, yhat_best.values) # naive estacional (m=12)
# Métricas del segundo mejor (si existe)
sec_mae = sec_mape = sec_smape = sec_wape = sec_mase1 = sec_mase12 = np.nan
if second_fit is not None:
yhat_sec = forecast_match_index(second_fit, start_2024, periods=H)
sec_mae = mae (y_test.values, yhat_sec.values)
sec_mape = mape (y_test.values, yhat_sec.values)
sec_smape = smape(y_test.values, yhat_sec.values)
sec_wape = wape (y_test.values, yhat_sec.values)
sec_mase1 = mase_no_fallback(y_train.values, y_test.values, yhat_sec.values, m=1)
sec_mase12 = mase12 (y_train.values, y_test.values, yhat_sec.values)
results.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"best_model": best_name,
"best_aic": best_aic,
"MAE_2024": m_mae,
"MAPE_2024": m_mape,
"SMAPE_2024": m_smape,
"WAPE_2024": m_wape,
"MASE1_2024": m_mase1, # nuevo
"MASE12_2024": m_mase12, # nuevo
"second_model": second_name,
"sec_MAE_2024": sec_mae,
"sec_MAPE_2024": sec_mape,
"sec_SMAPE_2024": sec_smape,
"sec_WAPE_2024": sec_wape,
"sec_MASE1_2024": sec_mase1, # nuevo
"sec_MASE12_2024": sec_mase12 # nuevo
})
# Guardamos pronóstico mensual del mejor modelo (junto con el real)
df_fc = pd.DataFrame({
"FECHA": test_months,
"y_true": y_test.values,
"y_pred_best": yhat_best.values
})
df_fc["ID_BUILDING"] = bid
df_fc["FM_COST_TYPE"] = ctype
df_fc["best_model"] = best_name
forecasts_rows.append(df_fc)
except Exception as e:
failed_pairs.append((bid, ctype, str(e)))
continue
# Resumen de resultados
res_df = pd.DataFrame(results)
print("Modelos evaluados:", res_df.shape[0], "de", len(pairs))
print(res_df.groupby("best_model").size().sort_values(ascending=False))
# Estadísticos globales (ignorando NaN donde aplique)
print("\nMAE global (media):", np.nanmean(res_df["MAE_2024"]).round(2))
print("SMAPE global (media):", np.nanmean(res_df["SMAPE_2024"]).round(2), "%")
print("WAPE global (media):", (100*np.nanmean(res_df["WAPE_2024"])).round(2), "%") # mostramos en %
print("MASE1 global (media):", np.nanmean(res_df["MASE1_2024"]).round(3), "(naive simple)")
print("MASE12 global (media):", np.nanmean(res_df["MASE12_2024"]).round(3), "(naive estacional)")
# TOP 10 mejores y peores (por WAPE, más robusto con ceros)
print("\nTOP 10 mejores (WAPE):")
print(res_df.nsmallest(10, "WAPE_2024")[["ID_BUILDING","FM_COST_TYPE","best_model","WAPE_2024","SMAPE_2024","MAE_2024","MASE1_2024","MASE12_2024"]])
print("\nTOP 10 peores (WAPE):")
print(res_df.nlargest(10, "WAPE_2024")[["ID_BUILDING","FM_COST_TYPE","best_model","WAPE_2024","SMAPE_2024","MAE_2024","MASE1_2024","MASE12_2024"]])
# Guardado opcional (descomentamos si queremos persistir)
# OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# out_csv_metrics = f"{OUT_DIR}/res_ITE1_ARIMA_vs_ETS_Metrics.csv"
# out_csv_forecasts = f"{OUT_DIR}/res_ITE1_ARIMA_vs_ETS_Forecasts.csv"
# res_df.to_csv(out_csv_metrics, index=False, sep=";")
# if forecasts_rows:
# fc_df = pd.concat(forecasts_rows, ignore_index=True)
# fc_df = fc_df[["ID_BUILDING","FM_COST_TYPE","FECHA","best_model","y_true","y_pred_best"]].sort_values(["ID_BUILDING","FM_COST_TYPE","FECHA"])
# fc_df.to_csv(out_csv_forecasts, index=False, sep=";")
# print("\nMétricas guardadas en:", out_csv_metrics)
# print("Pronósticos mensuales guardados en:", out_csv_forecasts)
# else:
# print("\nNo se generaron pronósticos (lista vacía).")
if failed_pairs:
print("\nParejas omitidas / con error (primeras 20):", failed_pairs[:20])
Modelos evaluados: 491 de 491 best_model ARIMA(0, 1, 1) 378 ARIMA(1, 1, 0) 59 ARIMA(1, 1, 1) 53 ARIMA(1, 0, 0) 1 dtype: int64 MAE global (media): 684.97 SMAPE global (media): 48.38 % WAPE global (media): 51.35 % MASE1 global (media): 1.266 (naive simple) MASE12 global (media): 0.589 (naive estacional) TOP 10 mejores (WAPE): ID_BUILDING FM_COST_TYPE best_model WAPE_2024 \ 189 1170 Mtto. Contratos ARIMA(1, 1, 0) 0.000000e+00 472 1001210 Eficiencia Energética ARIMA(1, 0, 0) 1.362106e-09 357 1000608 Mtto. Contratos ARIMA(1, 1, 1) 3.450481e-05 159 1087 Servicios Ctto. ARIMA(0, 1, 1) 4.398239e-03 132 1075 Servicios Ctto. ARIMA(0, 1, 1) 8.515159e-03 167 1092 Servicios Ctto. ARIMA(1, 1, 0) 1.612388e-02 149 1083 Servicios Ctto. ARIMA(0, 1, 1) 1.627627e-02 138 1078 Servicios Ctto. ARIMA(1, 1, 0) 1.644988e-02 151 1084 Eficiencia Energética ARIMA(1, 1, 0) 1.786921e-02 157 1087 Eficiencia Energética ARIMA(1, 1, 0) 1.786921e-02 SMAPE_2024 MAE_2024 MASE1_2024 MASE12_2024 189 0.000000e+00 0.000000 NaN NaN 472 1.362106e-07 0.000005 NaN NaN 357 3.450542e-03 0.006274 0.000285 0.000380 159 4.402388e-01 28.971608 0.146532 0.064394 132 8.548909e-01 38.041861 0.237467 0.146632 167 1.609209e+00 105.612472 0.540367 0.200763 149 1.624394e+00 83.972311 0.717664 0.247924 138 1.641636e+00 84.977638 0.594734 0.137377 151 1.797628e+00 5.229545 0.158276 0.026691 157 1.797628e+00 5.229545 0.158276 0.026691 TOP 10 peores (WAPE): ID_BUILDING FM_COST_TYPE best_model WAPE_2024 SMAPE_2024 \ 0 2 Suministros ARIMA(0, 1, 1) 2.878377 132.319195 211 1230 Servicios Extra ARIMA(0, 1, 1) 1.912367 119.527376 196 1197 Mtto. Correctivo ARIMA(0, 1, 1) 1.910372 120.880451 205 1216 Mtto. Correctivo ARIMA(1, 1, 1) 1.727976 109.445883 240 1283 Servicios Extra ARIMA(0, 1, 1) 1.724054 115.278783 321 1000480 Mtto. Correctivo ARIMA(0, 1, 1) 1.683877 110.157581 294 1000402 Servicios Extra ARIMA(0, 1, 1) 1.617967 124.498400 249 1306 Mtto. Correctivo ARIMA(0, 1, 1) 1.471232 93.030414 213 1238 Mtto. Correctivo ARIMA(0, 1, 1) 1.467608 99.511627 417 1001110 Suministros ARIMA(0, 1, 1) 1.312104 89.083955 MAE_2024 MASE1_2024 MASE12_2024 0 5779.024000 1.787252 0.605817 211 33.478846 0.514961 0.653429 196 323.488176 0.993703 1.051523 205 228.558071 0.804907 0.628191 240 56.393586 0.604781 0.708419 321 237.180739 0.540120 0.541849 294 464.856749 0.616470 0.643182 249 1781.311863 0.549598 0.405485 213 186.211861 0.827679 0.693953 417 505.486862 1.362183 0.548530
Interpretación¶
Con 491/491 parejas evaluadas, todos los ganadores por AIC son ARIMA (0,1,1 en el 77% de los casos).
ARIMA(0,1,1) domina porque d=1 captura bien la componente random-walk y el MA absorbe shocks.
Como curiosidad, ARIMA(0,1,1) es equivalente a un suavizado exponencial simple (ETS(A,N,N)) bajo ciertas condiciones.
Métricas globales (medias)
MAE ≈ 685: escala absoluta media del error.
SMAPE ≈ 48,4%: error relativo medio alto (series ruidosas/espigadas).
WAPE ≈ 51,35%: la mitad del volumen 2024 cae en error; es alto (hay mucha variabilidad y/o nivel bajo con picos).
MASE1 ≈ 1,266 (vs naive simple): de media, usar la predicción es peor que repetir el mes previo.
MASE12 ≈ 0,589 (vs naive estacional): de media, usar la predicción es mejor que repetir el valor de hace 12 meses.
Lectura conjunta: Las series tienen estacionalidad anual, pero en muchos casos el mes previo es un buen predictor (de ahí que MASE1 > 1). Aun así, el ARIMA seleccionado bate al naive estacional (MASE12 < 1).
Conclusiones prácticas
Selección por AIC nos ha llevado casi siempre a ARIMA(0,1,1) /familia. No es raro: es rápido, estable y competitivo en AIC.
AIC en train entre familias (ETS vs ARIMA) no siempre nos correlaciona con test. Si buscamos rendimiento en 2024, conviene un criterio de selección adicional (p. ej., val. 2023-Q4 o rolling origin) antes del test final.
Usaremos MASE (m) explícito en los informes: reporta MASE1 (naive simple) y MASE12 (naive estacional).
El hecho de que MASE12 < 1 globalmente es buena señal: el modelo añade valor frente a repetir el mismo mes del año anterior.
En cambio con MASE1 > 1 nos recuerda que el mes previo es duro de batir en bastantes parejas (autocorrelación alta).
WAPE ~51% sugiere que el conjunto es difícil:
Mucho cero + picos → distorsiona métricas y entrenamiento.
Para intermitentes, vale la pena integrar Croston/SBA como candidato y elegir campeón por pareja (Naive estacional vs ARIMA vs ETS/Croston) con WAPE/MASE12 como criterio.
Medidas para la higiene de las predicciones:
Truncar a no negativos y, si procede, capar outliers (winsor) antes de medir.
Considerar Box-Cox/log cuando el nivel varía mucho por pareja (evita que AIC favorezca soluciones “demasiado diferenciales”).
Próximos paso que maximiza valor:
Añadir Naive estacional como candidato explícito.
Hacer selector por pareja con ranking por MASE12, desempate por WAPE.
Donde el % de ceros sea alto, incluir Croston/SBA.
Un re-fit SARIMA sólo en subconjunto prometedor (bajo % ceros, estacionalidad clara) si queremos exprimir algo más.
Resumen ejecutivo
ARIMA domina por AIC y bate al naive estacional de media (MASE12=0,589).
No bate al naive simple de media (MASE1=1,266), lo que indica fuerte dependencia mes-a-mes.
WAPE y SMAPE son elevados por la intermitencia y picos en varias familias de coste.
Para cerrar el loop, selección por pareja (Naive estacional / ARIMA / ETS / Croston) con foco en MASE12 + WAPE nos dará una cesta final más robusta.
Guardamos metricas y predicciones del mejor modelo a Drive
# ================================================================
# Guardado homogéneo para el caso NO estacional (ETS / ARIMA)
# - Predicciones por pareja (2024)
# - Métricas por pareja
# Sufijo: ARIMA_ETS_NS (Non-Seasonal)
# ================================================================
base_dir = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
os.makedirs(base_dir, exist_ok=True)
# Construimos las tablas a guardar con nombres consistentes
# Predicciones por pareja (2024)
if forecasts_rows:
preds_ARIMA_ETS_2024 = pd.concat(forecasts_rows, ignore_index=True)
preds_ARIMA_ETS_2024 = preds_ARIMA_ETS_2024[[
"ID_BUILDING","FM_COST_TYPE","FECHA","best_model","y_true","y_pred_best"
]].copy()
else:
preds_ARIMA_ETS_2024 = pd.DataFrame(columns=[
"ID_BUILDING","FM_COST_TYPE","FECHA","best_model","y_true","y_pred_best"
])
# Métricas por pareja (añadimos campos estándar)
metrics_ARIMA_ETS_2024 = res_df.copy()
metrics_ARIMA_ETS_2024.insert(0, "model", "ARIMA_ETS_NS") # etiqueta de modelo agregada
# Si queremos contar observaciones del test (12 meses 2024), lo dejamos fijo:
metrics_ARIMA_ETS_2024["n_obs"] = 12
# Rutas de salida (siguiendo el patrón de nombres)
pred_arima_ets_path = os.path.join(base_dir, "df_pred_fd1_v5_ITE1_ARIMA_ETS_NS.csv")
metrics_arima_ets_path = os.path.join(base_dir, "df_metrics_fd1_v5_ITE1_ARIMA_ETS_NS.csv")
# Guardamos
if not preds_ARIMA_ETS_2024.empty:
preds_ARIMA_ETS_2024.to_csv(pred_arima_ets_path, sep=";", index=False)
else:
print("Aviso: no hay predicciones ARIMA_ETS_NS para guardar.")
metrics_ARIMA_ETS_2024.to_csv(metrics_arima_ets_path, index=False)
# Mensaje de confirmación
print("Guardados en Drive (NO estacional):")
print("Predicciones ARIMA_ETS_NS:", pred_arima_ets_path if not preds_ARIMA_ETS_2024.empty else "no disponibles")
print("Métricas ARIMA_ETS_NS :", metrics_arima_ets_path)
Guardados en Drive (NO estacional): Predicciones ARIMA_ETS_NS: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_pred_fd1_v5_ITE1_ARIMA_ETS_NS.csv Métricas ARIMA_ETS_NS : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_metrics_fd1_v5_ITE1_ARIMA_ETS_NS.csv
Graficamos previsiones vs real¶
# 1) Agregamos el portfolio por mes (y_true, y_pred_best) – asumimos que son datos 2024.
# 2) Construimos el TRAIN agregado SOLO hasta 2023-12 (sin 2024) para el denominador de MASE.
# 3) Calculamos MASE1/MASE12 directamente con los denominadores (evitamos dependencias de estado).
# 4) Graficamos con subtítulo de métricas.
# --- Validaciones y constantes ---
assert "preds_ARIMA_ETS_2024" in globals(), "No encontramos preds_ARIMA_ETS_2024."
assert "train_df" in globals(), "No encontramos train_df."
DATECOL = "FECHA"
TARGET = "cost_float_mod"
# Nos aseguramos de que FECHA sea datetime
preds_ARIMA_ETS_2024[DATECOL] = pd.to_datetime(preds_ARIMA_ETS_2024[DATECOL], errors="coerce")
train_df[DATECOL] = pd.to_datetime(train_df[DATECOL], errors="coerce")
# --- 1) Agregado de portfolio por mes (predicciones 2024) ---
agg = (
preds_ARIMA_ETS_2024
.groupby(DATECOL, as_index=False)[["y_true","y_pred_best"]]
.sum()
.sort_values(DATECOL)
.reset_index(drop=True)
)
# Reindex a mensual por robustez
idx = pd.date_range(agg[DATECOL].min(), agg[DATECOL].max(), freq="MS")
agg = (agg.set_index(DATECOL).reindex(idx).fillna(0.0).rename_axis(DATECOL).reset_index())
# --- 2) TRAIN agregado del mismo portfolio (solo < 2024-01-01) ---
pairs_port = preds_ARIMA_ETS_2024[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
# (Opcional) alineamos tipos para el merge si hubiera dudas:
# preds_ARIMA_ETS_2024["ID_BUILDING"] = preds_ARIMA_ETS_2024["ID_BUILDING"].astype(str)
# train_df["ID_BUILDING"] = train_df["ID_BUILDING"].astype(str)
train_port = (
train_df
.merge(pairs_port, on=["ID_BUILDING","FM_COST_TYPE"], how="inner")
[[DATECOL, TARGET]]
.copy()
)
train_port = train_port[train_port[DATECOL] < pd.Timestamp("2024-01-01")]
s_train = (
train_port
.groupby(DATECOL)[TARGET]
.sum()
.sort_index()
.asfreq("MS", fill_value=0.0)
.astype(float)
)
# --- 3) Métricas de portfolio y MASE directo (sin funciones externas) ---
y_true_port = agg["y_true"].to_numpy(dtype=float)
y_pred_port = agg["y_pred_best"].to_numpy(dtype=float)
MAE_port = mae (y_true_port, y_pred_port)
SMAPE_port = smape(y_true_port, y_pred_port) # %
WAPE_port = wape (y_true_port, y_pred_port) # 0–1
# Numerador de MASE (MAE sobre 2024) y denominadores del naive
num = float(np.mean(np.abs(y_true_port - y_pred_port))) # = MAE_port
z = s_train.to_numpy(dtype=float)
den1 = float(np.mean(np.abs(z[1:] - z[:-1] ))) if len(z) > 1 else np.nan
den12 = float(np.mean(np.abs(z[12:] - z[:-12]))) if len(z) > 12 else np.nan
MASE1_port = num / den1 if np.isfinite(den1) and den1 != 0 else np.nan
MASE12_port = num / den12 if np.isfinite(den12) and den12 != 0 else np.nan
print("Métricas PORTFOLIO (agregado mensual):")
print(f" - MAE : {MAE_port:,.3f}")
print(f" - SMAPE % : {SMAPE_port:,.3f}")
print(f" - WAPE : {WAPE_port:,.4f} ({100*WAPE_port:,.2f}%)")
print(f" - MASE1 : {MASE1_port}")
print(f" - MASE12 : {MASE12_port}")
# --- 4) Gráfico con subtítulo de métricas ---
plt.figure(figsize=(10,5))
plt.plot(agg[DATECOL], agg["y_true"], label="Real (portfolio)", marker="o")
plt.plot(agg[DATECOL], agg["y_pred_best"], label="Predicho (portfolio)", marker="x")
subtitle = (
f"MAE={num:,.0f} | WAPE={100*WAPE_port:,.1f}% | "
f"SMAPE={SMAPE_port:,.1f}% | MASE1={MASE1_port:.3f} | MASE12={MASE12_port:.3f}"
)
plt.title("Predicciones agregadas vs Realidad - Portfolio\n" + subtitle)
plt.xlabel("Mes")
plt.ylabel("Coste total")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Métricas PORTFOLIO (agregado mensual): - MAE : 131,540.511 - SMAPE % : 15.871 - WAPE : 0.1639 (16.39%) - MASE1 : 0.8746391961672845 - MASE12 : 0.2735416205281805
sns.scatterplot(data=preds_ARIMA_ETS_2024, x="y_true", y="y_pred_best", alpha=0.5)
plt.plot([0, preds_ARIMA_ETS_2024["y_true"].max()], [0, preds_ARIMA_ETS_2024["y_true"].max()], 'r--')
plt.title("Predicciones vs Realidad (todas las parejas)")
plt.xlabel("Real")
plt.ylabel("Predicho")
plt.show()
print("pairs en preds:", preds_ARIMA_ETS_2024[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0])
pairs_port = preds_ARIMA_ETS_2024[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
train_port = (train_df.merge(pairs_port, on=["ID_BUILDING","FM_COST_TYPE"], how="inner")
[["FECHA","cost_float_mod"]])
train_port["FECHA"] = pd.to_datetime(train_port["FECHA"])
s_train = (train_port.groupby("FECHA")["cost_float_mod"].sum()
.sort_index()
.asfreq("MS", fill_value=0.0))
print("len(s_train):", len(s_train), "min:", s_train.index.min(), "max:", s_train.index.max(),
"sum:", float(s_train.sum()))
import numpy as np
d1 = np.mean(np.abs(s_train[1:].values - s_train[:-1].values)) if len(s_train) > 1 else np.nan
d12 = np.mean(np.abs(s_train[12:].values - s_train[:-12].values)) if len(s_train) > 12 else np.nan
print("d1:", d1, "d12:", d12)
pairs en preds: 491 len(s_train): 36 min: 2021-01-01 00:00:00 max: 2023-12-01 00:00:00 sum: 33193056.901383586 d1: 150394.02725129225 d12: 480879.3296224533
np.isfinite(y_true_port).all(), np.isfinite(y_pred_port).all()
(np.True_, np.True_)
Interpretación y conclusiones¶
El diagnóstico con el que hemos clasificado cada pareja ha sido según los KPIs obtenidos de manera secuencial desde intermitencia, estacionalidad, outliers, etc., asignando un model_suggested
para cada caso. Solo después de los filtros establecidos para asignar un modelo y como descarte, se han marcado la pareja con un modelo sugerido “ETS_simple|ARIMA_basico” (diagnóstico = estable).
Por lo tanto, consideramos que es mejor iniciar primero con los grupso con asignaciones puras (ej. pooling_global|naive_0, Croston|SBA, ETS estacional|SARIMA, transform_log1p+winsor|robusto) puesto no entran en la duda ETS vs ARIMA u otras particulares.
Vamos a seguir por lo tanto con el Grupo train de CROSTON/SBA para ver si las predicciones que obtenemos son más consistentes, al no competir entre dos familias de modelización, el criterio de KPIs aplicado ya resolvió el camino que debemos seguir.
MODELIZACIÓN CROSTON/SBA¶
Teoría¶
- ¿Qué problema resuelven Croston y SBA?
Cuando tienes series temporales con muchos ceros (demanda intermitente), los modelos clásicos (ARIMA, ETS…) suelen fallar porque:
La mayoría de meses no hay gasto → el modelo predice todo a cero.
De repente aparece un gasto -> el modelo “se vuelve loco” porque no lo esperaba.
Ejemplo:
0, 0, 0, 2500, 0, 0, 0, 0, 1800, 0, 0, ...
Ahí es donde entra Croston.
- ¿Qué hace Croston?
Croston divide el problema en dos partes:
Tamaño de la demanda (qué tan grande es cuando aparece). Promedio suavizado de los valores positivos.
Intervalo entre demandas (cada cuántos meses aparece). Promedio suavizado de los huecos de ceros entre valores positivos.
Finalmente, combina las dos partes:
Forecast = Tamaño medio / Intervalo medio
O sea: “en promedio, cada X meses gasto Y -> entonces el gasto mensual esperado es Y/X”.
El resultado es un pronóstico constante (no cambia con el horizonte). Si pronosticas 12 meses, te dará 12 veces el mismo valor.
- ¿Qué añade SBA (Syntetos–Boylan Approximation)?
Se descubrió que el Croston clásico tiene un sesgo (tiende a sobreestimar). SBA corrige ese sesgo multiplicando por un factor:
Forecast SBA = (1-α2) x Forecast Croston
donde α es el parámetro de suavizado (0 < α ≤ 1). En la práctica, SBA da valores un poco más bajos y realistas.
- ¿Qué significa “aplicar Croston/SBA” en nuestro proyecto?
En nuestro caso (gasto operativo FM):
Para cada pareja (ID_BUILDING
, FM_COST_TYPE
), tenemos una serie mensual de costes.
Muchas de esas series son intermitentes: meses con 0 y de vez en cuando un gasto.
Aplicar Croston o SBA significa:
Entrenar esos promedios (tamaño de gasto y frecuencia) en el histórico (train).
Generar una predicción constante para cada mes del horizonte (test).
Comparar esas predicciones contra los valores reales de 2024 para medir el error (WAPE, SMAPE, etc.).
- ¿Qué NO hace Croston/SBA?
No modela tendencias (no va a “subir o bajar” con el tiempo).
No captura estacionalidad (siempre da el mismo valor esperado).
- Conclusion:
CROSTON/SBA sirve como baseline sólido para series con muchos ceros.
Cargamos el dataframe train para modelizar: df_train_fd1_v5_ITE1_CROSTON
¶
df_train_fd1_v5_ITE1_CROSTON
contiene todas las series diagnosticadas en el train (2021-2023) como CROSTON/SBA.
Cargamos sub_dataframe _CROSTON train y test desde Drive¶
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- OPCIÓN 1: Cargar desde CSV (con separador ';') ---
# csv_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_CROSTON.csv"
# df_train_fd1_v5_ITE1_CROSTON = pd.read_csv(csv_path, sep=';')
# --- OPCIÓN 2: Cargar desde Excel ---
xlsx_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_CROSTON.xlsx"
df_train_fd1_v5_ITE1_CROSTON = pd.read_excel(xlsx_path)
# Visualizamos forma y primeras filas para validar
print("Shape:", df_train_fd1_v5_ITE1_CROSTON.shape)
print(df_train_fd1_v5_ITE1_CROSTON.head())
# Normalizamos la fecha si existe
if "FECHA" in df_train_fd1_v5_ITE1_CROSTON.columns:
df_train_fd1_v5_ITE1_CROSTON["FECHA"] = pd.to_datetime(df_train_fd1_v5_ITE1_CROSTON["FECHA"], errors="coerce")
print("Cargado:", df_train_fd1_v5_ITE1_CROSTON.shape)
df_train_fd1_v5_ITE1_CROSTON.head(3)
Shape: (14460, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2021-01-01 2021 1 1145.46 1 2 Licencias 2021-02-01 2021 2 55.95 2 2 Licencias 2021-03-01 2021 3 0.00 3 2 Licencias 2021-04-01 2021 4 0.00 4 2 Licencias 2021-05-01 2021 5 305.95 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas INTERNO 1 España 2 Oficinas INTERNO 2 España 2 Oficinas INTERNO 3 España 2 Oficinas INTERNO 4 España 2 Oficinas INTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Licencias intermitente Croston|SBA CROSTON 1 Licencias intermitente Croston|SBA CROSTON 2 Licencias intermitente Croston|SBA CROSTON 3 Licencias intermitente Croston|SBA CROSTON 4 Licencias intermitente Croston|SBA CROSTON Cargado: (14460, 14)
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | diagnosis | model_suggested | model_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Licencias | 2021-01-01 | 2021 | 1 | 1145.46 | España | 2 | Oficinas | INTERNO | Licencias | intermitente | Croston|SBA | CROSTON |
1 | 2 | Licencias | 2021-02-01 | 2021 | 2 | 55.95 | España | 2 | Oficinas | INTERNO | Licencias | intermitente | Croston|SBA | CROSTON |
2 | 2 | Licencias | 2021-03-01 | 2021 | 3 | 0.00 | España | 2 | Oficinas | INTERNO | Licencias | intermitente | Croston|SBA | CROSTON |
Cargamos test
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- OPCIÓN 1: Cargar desde CSV (con separador ';') ---
# csv_path = f"{OUT_DIR}/df_test_fd1_v5_ITE1_CROSTON.csv"
# df_test_fd1_v5_ITE1_CROSTON = pd.read_csv(csv_path, sep=';')
# --- OPCIÓN 2: Cargar desde Excel ---
xlsx_path = f"{OUT_DIR}/df_test_fd1_v5_ITE1_CROSTON.xlsx"
df_test_fd1_v5_ITE1_CROSTON = pd.read_excel(xlsx_path)
# Visualizamos forma y primeras filas para validar
print("Shape:", df_test_fd1_v5_ITE1_CROSTON.shape)
print(df_test_fd1_v5_ITE1_CROSTON.head())
# Normalizamos fecha
if "FECHA" in df_test_fd1_v5_ITE1_CROSTON.columns:
df_test_fd1_v5_ITE1_CROSTON["FECHA"] = pd.to_datetime(df_test_fd1_v5_ITE1_CROSTON["FECHA"], errors="coerce")
print("Cargado:", df_test_fd1_v5_ITE1_CROSTON.shape)
df_test_fd1_v5_ITE1_CROSTON.head(3)
Shape: (4680, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2024-12-01 2024 12 202.8 1 2 Obras 2024-02-01 2024 2 54.6 2 2 Servicios Extra 2024-01-01 2024 1 0.0 3 2 Servicios Extra 2024-02-01 2024 2 0.0 4 9 Licencias 2024-01-01 2024 1 1600.0 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas INTERNO 1 España 2 Oficinas INTERNO 2 España 2 Oficinas INTERNO 3 España 2 Oficinas INTERNO 4 España 2 Oficinas INTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Licencias intermitente Croston|SBA CROSTON 1 Gestión Espacios intermitente Croston|SBA CROSTON 2 Mantenimiento intermitente Croston|SBA CROSTON 3 Mantenimiento intermitente Croston|SBA CROSTON 4 Licencias intermitente Croston|SBA CROSTON Cargado: (4680, 14)
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | diagnosis | model_suggested | model_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Licencias | 2024-12-01 | 2024 | 12 | 202.8 | España | 2 | Oficinas | INTERNO | Licencias | intermitente | Croston|SBA | CROSTON |
1 | 2 | Obras | 2024-02-01 | 2024 | 2 | 54.6 | España | 2 | Oficinas | INTERNO | Gestión Espacios | intermitente | Croston|SBA | CROSTON |
2 | 2 | Servicios Extra | 2024-01-01 | 2024 | 1 | 0.0 | España | 2 | Oficinas | INTERNO | Mantenimiento | intermitente | Croston|SBA | CROSTON |
Conciliamos ambos conjuntos fijando las series de 2025.¶
Ahora vamos a eliminar de train aquellas parejas que no estan en test. No necesitamos predecir.
# Creamos set de parejas válidas a partir del test
parejas_test = set(zip(
df_test_fd1_v5_ITE1_CROSTON["ID_BUILDING"],
df_test_fd1_v5_ITE1_CROSTON["FM_COST_TYPE"]
))
# Filtramos train
df_train_fd1_v5_ITE1_CROSTON_filtrado = df_train_fd1_v5_ITE1_CROSTON[
df_train_fd1_v5_ITE1_CROSTON.apply(
lambda r: (r["ID_BUILDING"], r["FM_COST_TYPE"]) in parejas_test, axis=1
)
].copy()
print("Shape original train:", df_train_fd1_v5_ITE1_CROSTON.shape)
print("Shape filtrado train:", df_train_fd1_v5_ITE1_CROSTON_filtrado.shape)
# Comprobamos que todas las parejas en train filtrado estén en test
faltantes = set(zip(
df_train_fd1_v5_ITE1_CROSTON_filtrado["ID_BUILDING"],
df_train_fd1_v5_ITE1_CROSTON_filtrado["FM_COST_TYPE"]
)) - parejas_test
print("Parejas en train filtrado que no están en test:", faltantes)
Shape original train: (14460, 14) Shape filtrado train: (14272, 14) Parejas en train filtrado que no están en test: set()
Entrenamos Croston/SBA masivo sobre df_train_fd1_v5_ITE1_CROSTON
.
Generamos pronósticos con horizonte = nº de meses que tengamos en test por pareja.
Hacemos el merge con los reales de test.
Calculamos métricas (WAPE, SMAPE).
Guardamos en Drive dos outputs:
Predicciones + reales por pareja (sufijo _croston).
DataFrame de métricas por pareja (sufijo _croston_metrics).
Definimos funciones Croston/SBA¶
def ensure_1d_array(x):
arr = np.asarray(x, dtype=float).copy()
return np.where(np.isnan(arr), 0.0, arr)
def _first_positive(y):
idx = np.where(y > 0)[0]
return None if len(idx) == 0 else int(idx[0])
def croston_classic(y, alpha=0.1, h=12):
y = ensure_1d_array(y)
n = len(y)
if n == 0 or np.all(y == 0):
return np.zeros(h)
first = _first_positive(y)
if first is None:
return np.zeros(h)
z = y[y > 0]
intervals = []
last = -1
for t, val in enumerate(y):
if val > 0:
if last >= 0:
intervals.append(t - last)
last = t
if len(intervals) == 0:
demand_hat = z.mean()
interval_hat = n
else:
demand_hat = z[0]
interval_hat = intervals[0]
k = 0
for t, val in enumerate(y):
if val > 0:
demand_hat = demand_hat + alpha*(val - demand_hat)
if k < len(intervals):
interval_hat = interval_hat + alpha*(intervals[k] - interval_hat)
k += 1
forecast = demand_hat / max(interval_hat, 1e-8)
return np.full(h, forecast)
def croston_sba(y, alpha=0.1, h=12):
base = croston_classic(y, alpha=alpha, h=1)[0]
sba_forecast = (1 - alpha/2.0) * base
return np.full(h, sba_forecast)
Realizamos el entrenamiento masivo y calculamos las predicciones o forecast¶
# Aseguramos fechas
df_train = df_train_fd1_v5_ITE1_CROSTON.copy()
df_train["FECHA"] = pd.to_datetime(df_train["FECHA"])
df_test = df_test_fd1_v5_ITE1_CROSTON.copy()
df_test["FECHA"] = pd.to_datetime(df_test["FECHA"])
out_all = []
for (bid, ctype), g_train in df_train.groupby(["ID_BUILDING","FM_COST_TYPE"]):
g_train = g_train.sort_values("FECHA")
g_test = df_test[(df_test["ID_BUILDING"]==bid) & (df_test["FM_COST_TYPE"]==ctype)].sort_values("FECHA")
if g_test.empty:
continue
h = len(g_test) # horizonte = nº meses de test
y_train = g_train["cost_float_mod"].fillna(0).values
preds = croston_sba(y_train, alpha=0.1, h=h) # usamos SBA directo
df_pred = pd.DataFrame({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"FECHA": g_test["FECHA"].values,
"yhat": preds
})
df_pred = df_pred.merge(
g_test[["FECHA","cost_float_mod"]],
on="FECHA", how="left"
).rename(columns={"cost_float_mod":"y"})
out_all.append(df_pred)
df_pred_all = pd.concat(out_all, ignore_index=True)
print("Predicciones generadas:", df_pred_all.shape)
df_pred_all.head()
Predicciones generadas: (4680, 5)
ID_BUILDING | FM_COST_TYPE | FECHA | yhat | y | |
---|---|---|---|---|---|
0 | 2 | Licencias | 2024-12-01 | 353.798468 | 202.8 |
1 | 2 | Obras | 2024-02-01 | 130.811893 | 54.6 |
2 | 2 | Servicios Extra | 2024-01-01 | 116.867916 | 0.0 |
3 | 2 | Servicios Extra | 2024-02-01 | 116.867916 | 0.0 |
4 | 9 | Licencias | 2024-01-01 | 253.568541 | 1600.0 |
Cálculamos las métricas para cada serie o pareja¶
metrics = (df_pred_all.dropna(subset=["y"])
.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False)
.apply(lambda g: pd.Series({
"WAPE": wape(g["y"].values, g["yhat"].values),
"SMAPE": smape(g["y"].values, g["yhat"].values)
}))
.reset_index(drop=True))
print("Métricas calculadas:", metrics.shape)
metrics.head()
Métricas calculadas: (472, 4)
ID_BUILDING | FM_COST_TYPE | WAPE | SMAPE | |
---|---|---|---|---|
0 | 2 | Licencias | 0.744568 | 54.257594 |
1 | 2 | Obras | 1.395822 | 82.208203 |
2 | 2 | Servicios Extra | NaN | 200.000000 |
3 | 9 | Licencias | 1.331861 | 168.038835 |
4 | 9 | Obras | 1.008057 | 128.720687 |
Guardamos metricas y predicciones en Drive¶
base_dir = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# Guardamos predicciones con reales
pred_path = os.path.join(base_dir, "df_pred_fd1_v5_ITE1_croston.csv")
df_pred_all.to_csv(pred_path, index=False)
# Guardamos métricas
metrics_path = os.path.join(base_dir, "df_metrics_fd1_v5_ITE1_croston.csv")
metrics.to_csv(metrics_path, index=False)
print("Guardados en Drive:")
print("Predicciones:", pred_path)
print("Métricas:", metrics_path)
Guardados en Drive: Predicciones: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_pred_fd1_v5_ITE1_croston.csv Métricas: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_metrics_fd1_v5_ITE1_croston.csv
Teoría¶
Recordatorio de como interpretar las métricas
Recordemos qué son las métricas
WAPE (Weighted Absolute Percentage Error):
WAPE = ∑|Y-YPRED)|/∑|Y|
Es como un error porcentual relativo al total.
0% sería perfecto.
100% significa que el error equivale al gasto total.
SMAPE (Symmetric Mean Absolute Percentage Error):
SMAPE = 200/n x ∑ [(|y-yPRED|) / (|y|+|yPRED|)]
Acota el error entre 0% y 200%.
0% sería perfecto.
100% significa que de media estamos tan lejos como el valor mismo.
- Referencias típicas en series de gasto / demanda
Como referencia, en práctica:
WAPE < 20% → modelo muy bueno.
20% ≤ WAPE < 40% → aceptable.
40% ≤ WAPE < 60% → dudoso, probablemente haya que mejorar.
WAPE > 60% → error muy alto.
Para SMAPE, se interpreta parecido (menor significa mejor):
SMAPE < 20% → excelente.
20–40% → bueno.
40–60% → débil.
SMAPE > 60% → error elevado.
Calculamos la media global de las métricas¶
print("Resumen global CROSTON/SBA")
print("WAPE medio:", metrics["WAPE"].mean())
print("SMAPE medio:", metrics["SMAPE"].mean())
print("WAPE mediana:", metrics["WAPE"].median())
print("SMAPE mediana:", metrics["SMAPE"].median())
Resumen global CROSTON/SBA WAPE medio: 1.163753551902763 SMAPE medio: 132.87407307842736 WAPE mediana: 0.9888819586917396 SMAPE mediana: 136.28199442702885
Visualizamos la distribución de errores¶
metrics["WAPE"].hist(bins=30)
<Axes: >
metrics["SMAPE"].hist(bins=30)
<Axes: >
Realizamos un ranking de las mejores y las peores¶
# Mejores 10 (por WAPE)
top10 = metrics.sort_values("WAPE").head(10)
print("TOP 10 mejores series (WAPE bajo):")
print(top10)
# Peores 10 (por WAPE)
worst10 = metrics.sort_values("WAPE", ascending=False).head(10)
print("\nTOP 10 peores series (WAPE alto):")
print(worst10)
TOP 10 mejores series (WAPE bajo): ID_BUILDING FM_COST_TYPE WAPE SMAPE 6 57 Mtto. Contratos 0.001871 0.186971 112 681 Servicios Extra 0.051078 4.980576 286 1000667 Mtto. Contratos 0.159152 14.742060 178 1573 Mtto. Correctivo 0.183697 20.227607 357 1001123 Servicios Extra 0.232948 26.365772 241 1000461 Mtto. Contratos 0.264129 18.269656 295 1000764 Mtto. Contratos 0.293021 34.332114 298 1000765 Mtto. Contratos 0.298461 35.081329 291 1000758 Mtto. Contratos 0.308637 36.495657 228 1000434 Servicios Extra 0.383595 32.186263 TOP 10 peores series (WAPE alto): ID_BUILDING FM_COST_TYPE WAPE SMAPE 7 59 Servicios Ctto. 21.826612 184.806609 390 1001151 Mtto. Correctivo 8.786301 173.397598 212 1000409 Licencias 8.508665 183.460664 356 1001123 Mtto. Correctivo 5.391055 162.180979 120 1007 Licencias 4.173347 147.943091 449 1001344 Mtto. Correctivo 3.996184 169.024318 471 1001400 Servicios Extra 3.967161 146.298757 122 1015 Mtto. Correctivo 3.654344 146.367398 9 105 Licencias 3.418017 193.509385 172 1317 Mtto. Correctivo 3.082614 140.917842
Interpretación¶
El resultado es horrible. Explicar luego
MODELIZACION ETS ESTACIONAL / SARIMA¶
Para la modelización ETS Estacional / SARIMA en nuestro proyecto de predicción de OPEX FM, debemos tener en cuenta el contexto de las series temporales que manejamos:
ETS Estacional (Error-Trend-Seasonal)
Se basa en modelos de suavizado exponencial, muy adecuados para series con tendencia y estacionalidad regular.
Funciona bien en horizontes de predicción de corto a medio plazo, cuando la estacionalidad es clara (ej. mensual o trimestral).
Se define a partir de componentes de error (A/M), tendencia (N/A/M, aditiva o multiplicativa) y estacionalidad (N/A/M).
Ejemplo: ETS(A,A,M) con estacionalidad mensual captura efectos de tendencia lineal y estacionalidad multiplicativa (idóneo si los costes crecen proporcionalmente con la magnitud).
SARIMA (Seasonal ARIMA)
Extiende ARIMA para manejar estacionalidad explícita con parámetros (P,D,Q,s).
Muy flexible: combina diferenciación regular y estacional con autorregresivos y medias móviles.
Adecuado para series con picos o caídas en meses concretos del año (ej. facturas de suministros en verano/invierno).
Permite capturar tanto la estructura a corto plazo como patrones estacionales más largos.
Cargamos los dataset de train y de test con sufijo _SARIMA.¶
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- OPCIÓN 1: Cargar desde CSV (con separador ';') ---
# csv_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_SARIMA.csv"
# df_train_fd1_v5_ITE1_SARIMA = pd.read_csv(csv_path, sep=';')
# --- OPCIÓN 2: Cargar desde Excel ---
xlsx_path = f"{OUT_DIR}/df_train_fd1_v5_ITE1_SARIMA.xlsx"
df_train_fd1_v5_ITE1_SARIMA = pd.read_excel(xlsx_path)
# Visualizamos forma y primeras filas para validar
print("Shape:", df_train_fd1_v5_ITE1_SARIMA.shape)
print(df_train_fd1_v5_ITE1_SARIMA.head())
# Normalizamos la fecha si existe
if "FECHA" in df_train_fd1_v5_ITE1_SARIMA.columns:
df_train_fd1_v5_ITE1_SARIMA["FECHA"] = pd.to_datetime(df_train_fd1_v5_ITE1_SARIMA["FECHA"], errors="coerce")
print("Cargado:", df_train_fd1_v5_ITE1_SARIMA.shape)
df_train_fd1_v5_ITE1_SARIMA.head(3)
Shape: (17207, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Mtto. Contratos 2021-01-01 2021 1 412.50 1 2 Mtto. Contratos 2021-02-01 2021 2 0.00 2 2 Mtto. Contratos 2021-03-01 2021 3 0.00 3 2 Mtto. Contratos 2021-04-01 2021 4 950.92 4 2 Mtto. Contratos 2021-05-01 2021 5 1206.55 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas EXTERNO 1 España 2 Oficinas EXTERNO 2 España 2 Oficinas EXTERNO 3 España 2 Oficinas EXTERNO 4 España 2 Oficinas EXTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Mantenimiento estacional ETS estacional|SARIMA SARIMA 1 Mantenimiento estacional ETS estacional|SARIMA SARIMA 2 Mantenimiento estacional ETS estacional|SARIMA SARIMA 3 Mantenimiento estacional ETS estacional|SARIMA SARIMA 4 Mantenimiento estacional ETS estacional|SARIMA SARIMA Cargado: (17207, 14)
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | diagnosis | model_suggested | model_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Mtto. Contratos | 2021-01-01 | 2021 | 1 | 412.5 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
1 | 2 | Mtto. Contratos | 2021-02-01 | 2021 | 2 | 0.0 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
2 | 2 | Mtto. Contratos | 2021-03-01 | 2021 | 3 | 0.0 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
# Definimos la carpeta de salida en Drive
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
# --- OPCIÓN 1: Cargar desde CSV (con separador ';') ---
# csv_path = f"{OUT_DIR}/df_test_fd1_v5_ITE1_SARIMA.csv"
# df_test_fd1_v5_ITE1_SARIMA = pd.read_csv(csv_path, sep=';')
# --- OPCIÓN 2: Cargar desde Excel ---
xlsx_path = f"{OUT_DIR}/df_test_fd1_v5_ITE1_SARIMA.xlsx"
df_test_fd1_v5_ITE1_SARIMA = pd.read_excel(xlsx_path)
# Visualizamos forma y primeras filas para validar
print("Shape:", df_test_fd1_v5_ITE1_SARIMA.shape)
print(df_test_fd1_v5_ITE1_SARIMA.head())
# Normalizamos la fecha si existe
if "FECHA" in df_test_fd1_v5_ITE1_SARIMA.columns:
df_test_fd1_v5_ITE1_SARIMA["FECHA"] = pd.to_datetime(df_test_fd1_v5_ITE1_SARIMA["FECHA"], errors="coerce")
print("Cargado:", df_test_fd1_v5_ITE1_SARIMA.shape)
df_test_fd1_v5_ITE1_SARIMA.head(3)
Shape: (5648, 14) ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Mtto. Contratos 2024-01-01 2024 1 1874.00 1 2 Mtto. Contratos 2024-02-01 2024 2 0.00 2 2 Mtto. Contratos 2024-03-01 2024 3 0.00 3 2 Mtto. Contratos 2024-04-01 2024 4 175.58 4 2 Mtto. Contratos 2024-05-01 2024 5 175.58 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas EXTERNO 1 España 2 Oficinas EXTERNO 2 España 2 Oficinas EXTERNO 3 España 2 Oficinas EXTERNO 4 España 2 Oficinas EXTERNO FM_RESPONSIBLE_MOD diagnosis model_suggested model_group 0 Mantenimiento estacional ETS estacional|SARIMA SARIMA 1 Mantenimiento estacional ETS estacional|SARIMA SARIMA 2 Mantenimiento estacional ETS estacional|SARIMA SARIMA 3 Mantenimiento estacional ETS estacional|SARIMA SARIMA 4 Mantenimiento estacional ETS estacional|SARIMA SARIMA Cargado: (5648, 14)
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | diagnosis | model_suggested | model_group | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Mtto. Contratos | 2024-01-01 | 2024 | 1 | 1874.0 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
1 | 2 | Mtto. Contratos | 2024-02-01 | 2024 | 2 | 0.0 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
2 | 2 | Mtto. Contratos | 2024-03-01 | 2024 | 3 | 0.0 | España | 2 | Oficinas | EXTERNO | Mantenimiento | estacional | ETS estacional|SARIMA | SARIMA |
Preparamos dataframes¶
# En este bloque preparamos el sub-dataframe _SARIMA ya cargado:
# 1) Aseguramos tipos y columnas clave
# 2) Filtramos TEST a 2024
# 3) Obtenemos la intersección de parejas presentes en TRAIN y TEST-2024
# 4) Dejamos listas las constantes para modelización (ETS/SARIMA) en pasos siguientes
# Comprobamos que los dataframes esperados existen
assert "df_train_fd1_v5_ITE1_SARIMA" in globals(), "No está cargado df_train_fd1_v5_ITE1_SARIMA"
assert "df_test_fd1_v5_ITE1_SARIMA" in globals(), "No está cargado df_test_fd1_v5_ITE1_SARIMA"
# Normalizamos FECHA por seguridad
for _df in [df_train_fd1_v5_ITE1_SARIMA, df_test_fd1_v5_ITE1_SARIMA]:
if "FECHA" in _df.columns:
_df["FECHA"] = pd.to_datetime(_df["FECHA"], errors="coerce")
# Definimos claves y variable target
KEY = ["ID_BUILDING", "FM_COST_TYPE"]
TARGET = "cost_float_mod"
# Validamos presencia de columnas clave
faltan = [c for c in (KEY + ["FECHA", TARGET]) if c not in df_train_fd1_v5_ITE1_SARIMA.columns]
assert not faltan, f"Faltan columnas en TRAIN: {faltan}"
faltan = [c for c in (KEY + ["FECHA", TARGET]) if c not in df_test_fd1_v5_ITE1_SARIMA.columns]
assert not faltan, f"Faltan columnas en TEST: {faltan}"
# Filtramos TEST a 2024
df_test_2024 = df_test_fd1_v5_ITE1_SARIMA[df_test_fd1_v5_ITE1_SARIMA["FECHA"].dt.year == 2024].copy()
# Calculamos parejas presentes en ambos conjuntos
parejas_train = set(map(tuple, df_train_fd1_v5_ITE1_SARIMA[KEY].drop_duplicates().values))
parejas_test = set(map(tuple, df_test_2024[KEY].drop_duplicates().values))
parejas = sorted(parejas_train.intersection(parejas_test))
print("Resumen conjuntos _SARIMA:")
print(" - TRAIN shape:", df_train_fd1_v5_ITE1_SARIMA.shape)
print(" - TEST shape:", df_test_fd1_v5_ITE1_SARIMA.shape)
print(" - TEST 2024 shape:", df_test_2024.shape)
print(" - Parejas en TRAIN:", len(parejas_train))
print(" - Parejas en TEST-2024:", len(parejas_test))
print(" - Parejas comunes a modelizar:", len(parejas))
print("Ejemplo de parejas:", parejas[:5])
# Dejamos constantes de frecuencia estacional para pasos siguientes
FREQ = "MS" # frecuencia mensual (Month Start)
SEASONAL_PERIODS = 12 # estacionalidad anual
Resumen conjuntos _SARIMA: - TRAIN shape: (17207, 14) - TEST shape: (5648, 14) - TEST 2024 shape: (5648, 14) - Parejas en TRAIN: 489 - Parejas en TEST-2024: 481 - Parejas comunes a modelizar: 481 Ejemplo de parejas: [(2, 'Mtto. Contratos'), (2, 'Mtto. Correctivo'), (2, 'Servicios Ctto.'), (9, 'Mtto. Contratos'), (9, 'Servicios Extra')]
Confirmamos que los dos dataframes son correctos.
Bloque 1 - Comprobaciones¶
En este bloque:
Definimos constantes (
TARGET
,DATECOL
,FREQ
ySEASONAL_PERIODS
).Verificamos que están cargados los dataframes TRAIN/TEST y normalizamos
FECHA
.Verificamos que existen las funciones
wape
,smape
,mae
,mape
ymase
, y la utilidadensure_ts
.Preparamos TEST 2024 y la lista de parejas a modelizar (intersección TRAIN-TEST).
# Definimos constantes de proyecto
TARGET = "cost_float_mod"
DATECOL = "FECHA"
FREQ = "MS" # frecuencia mensual
SEASONAL_PERIODS = 12 # estacionalidad anual
# Comprobamos dataframes en memoria
assert "df_train_fd1_v5_ITE1_SARIMA" in globals(), "No está cargado df_train_fd1_v5_ITE1_SARIMA"
assert "df_test_fd1_v5_ITE1_SARIMA" in globals(), "No está cargado df_test_fd1_v5_ITE1_SARIMA"
# Normalizamos FECHA por seguridad
for _df in [df_train_fd1_v5_ITE1_SARIMA, df_test_fd1_v5_ITE1_SARIMA]:
if DATECOL in _df.columns:
_df[DATECOL] = pd.to_datetime(_df[DATECOL], errors="coerce")
# Verificamos columnas clave
for c in ["ID_BUILDING", "FM_COST_TYPE", DATECOL, TARGET]:
assert c in df_train_fd1_v5_ITE1_SARIMA.columns, f"Falta {c} en TRAIN"
assert c in df_test_fd1_v5_ITE1_SARIMA.columns, f"Falta {c} en TEST"
# Verificamos que las métricas y la utilidad ensure_ts ya existen
for fnm in ["wape", "smape", "mae", "mape", "mase"]:
assert fnm in globals(), f"No encontramos la función {fnm}() en el entorno."
for fnu in ["ensure_ts", "pair_series"]:
assert fnu in globals(), f"No encontramos la utilidad {fnu}() en el entorno."
# Obtenemos la serie mensual continua por pareja usando ensure_ts -> guardamos a utilidades
# def pair_series(df, pair):
# b, t = pair
# df_pair = df[(df["ID_BUILDING"] == b) & (df["FM_COST_TYPE"] == t)][[DATECOL, TARGET]].copy()
# return ensure_ts(df_pair) # devuelve serie mensual indexada por fecha (MS), float, ordenada
# Preparamos índice mensual 2024 y filtramos TEST (si procede)
idx_2024 = pd.date_range("2024-01-01", "2024-12-01", freq="MS")
df_test_2024 = df_test_fd1_v5_ITE1_SARIMA[df_test_fd1_v5_ITE1_SARIMA[DATECOL].dt.year == 2024].copy()
# Calculamos parejas comunes
parejas_train = set(map(tuple, df_train_fd1_v5_ITE1_SARIMA[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().values))
parejas_test = set(map(tuple, df_test_2024[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().values))
parejas = sorted(parejas_train.intersection(parejas_test))
print("Resumen inicial:")
print(" - TRAIN:", df_train_fd1_v5_ITE1_SARIMA.shape)
print(" - TEST :", df_test_fd1_v5_ITE1_SARIMA.shape)
print(" - TEST 2024:", df_test_2024.shape)
print(" - Parejas comunes:", len(parejas))
Resumen inicial: - TRAIN: (17207, 14) - TEST : (5648, 14) - TEST 2024: (5648, 14) - Parejas comunes: 481
Bloque 2 · Predicciones ETS estacional (aditivo) para 2024¶
En este bloque:
Ajustamos ETS con tendencia y estacionalidad aditivas por pareja (robusto a ceros).
Generamos las predicciones para los 12 meses de 2024 y guardamos y_real vs y_hat.
def predecir_ets(y_train, n_steps, seasonal_periods=SEASONAL_PERIODS):
# Requerimos al menos dos ciclos estacionales para estabilidad
if y_train is None or len(y_train) < seasonal_periods * 2:
return None
try:
model = ExponentialSmoothing(
y_train,
trend="add",
seasonal="add",
seasonal_periods=seasonal_periods,
initialization_method="estimated"
).fit(optimized=True, use_brute=False)
return np.asarray(model.forecast(n_steps), dtype=float)
except Exception:
return None
registros_ets = []
for (b, t) in parejas:
y_tr = serie_mensual(df_train_fd1_v5_ITE1_SARIMA, (b, t), col_y=TARGET)
y_te = serie_mensual(df_test_2024, (b, t), col_y=TARGET)
y_te_2024 = y_te.reindex(idx_2024).fillna(0.0)
yhat = predecir_ets(y_tr.values, n_steps=len(idx_2024))
if yhat is None:
continue
for fecha, real, pred in zip(idx_2024, y_te_2024.values, yhat):
registros_ets.append({
"ID_BUILDING": b,
"FM_COST_TYPE": t,
"FECHA": fecha,
"y_real": float(real),
"y_hat": float(pred),
"model": "ETS"
})
preds_ETS_2024 = pd.DataFrame(registros_ets)
print("Predicciones ETS 2024 generadas:", preds_ETS_2024.shape)
print(preds_ETS_2024.head(10))
/usr/local/lib/python3.12/dist-packages/statsmodels/tsa/holtwinters/model.py:903: ConvergenceWarning: Optimization failed to converge. Check mle_retvals. warnings.warn( /usr/local/lib/python3.12/dist-packages/statsmodels/tsa/holtwinters/model.py:903: ConvergenceWarning: Optimization failed to converge. Check mle_retvals. warnings.warn( /usr/local/lib/python3.12/dist-packages/statsmodels/tsa/holtwinters/model.py:903: ConvergenceWarning: Optimization failed to converge. Check mle_retvals. warnings.warn( /usr/local/lib/python3.12/dist-packages/statsmodels/tsa/holtwinters/model.py:903: ConvergenceWarning: Optimization failed to converge. Check mle_retvals. warnings.warn(
Predicciones ETS 2024 generadas: (5700, 6) ID_BUILDING FM_COST_TYPE FECHA y_real y_hat model 0 2 Mtto. Contratos 2024-01-01 1874.00 3408.995613 ETS 1 2 Mtto. Contratos 2024-02-01 0.00 1786.092913 ETS 2 2 Mtto. Contratos 2024-03-01 0.00 1786.086291 ETS 3 2 Mtto. Contratos 2024-04-01 175.58 2598.503450 ETS 4 2 Mtto. Contratos 2024-05-01 175.58 3035.778366 ETS 5 2 Mtto. Contratos 2024-06-01 175.58 1786.088753 ETS 6 2 Mtto. Contratos 2024-07-01 175.58 2543.745587 ETS 7 2 Mtto. Contratos 2024-08-01 175.58 1860.816923 ETS 8 2 Mtto. Contratos 2024-09-01 175.58 1946.786141 ETS 9 2 Mtto. Contratos 2024-10-01 175.58 2410.544310 ETS
Bloque 3 · Predicciones SARIMA estacional para 2024¶
En este bloque:
Probamos una rejilla ligera de órdenes SARIMA por pareja y elegimos la mejor por AIC.
Generamos predicciones para 2024 y guardamos y_real vs y_hat.
SARIMA_PDQs = [(0,1,1), (1,1,1)]
SARIMA_sPDQs = [(0,1,1,SEASONAL_PERIODS), (1,1,1,SEASONAL_PERIODS)]
def predecir_sarima(y_train, n_steps, pdqs=SARIMA_PDQs, spdqs=SARIMA_sPDQs):
# Exigimos al menos dos ciclos estacionales para estabilidad
if y_train is None or len(y_train) < SEASONAL_PERIODS * 2:
return None
best_aic = np.inf
best_res = None
for (p,d,q) in pdqs:
for (P,D,Q,s) in spdqs:
try:
model = SARIMAX(
y_train,
order=(p,d,q),
seasonal_order=(P,D,Q,s),
enforce_stationarity=False,
enforce_invertibility=False
)
res = model.fit(disp=False)
if res.aic < best_aic:
best_aic = res.aic
best_res = res
except Exception:
continue
if best_res is None:
return None
try:
return np.asarray(best_res.get_forecast(steps=n_steps).predicted_mean, dtype=float)
except Exception:
return None
registros_sarima = []
for (b, t) in parejas:
y_tr = serie_mensual(df_train_fd1_v5_ITE1_SARIMA, (b, t), col_y=TARGET)
y_te = serie_mensual(df_test_2024, (b, t), col_y=TARGET)
y_te_2024 = y_te.reindex(idx_2024).fillna(0.0)
yhat = predecir_sarima(y_tr.values, n_steps=len(idx_2024))
if yhat is None:
continue
for fecha, real, pred in zip(idx_2024, y_te_2024.values, yhat):
registros_sarima.append({
"ID_BUILDING": b,
"FM_COST_TYPE": t,
"FECHA": fecha,
"y_real": float(real),
"y_hat": float(pred),
"model": "SARIMA"
})
preds_SARIMA_2024 = pd.DataFrame(registros_sarima)
print("Predicciones SARIMA 2024 generadas:", preds_SARIMA_2024.shape)
print(preds_SARIMA_2024.head(10))
/usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to " /usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to "
Predicciones SARIMA 2024 generadas: (5700, 6) ID_BUILDING FM_COST_TYPE FECHA y_real y_hat model 0 2 Mtto. Contratos 2024-01-01 1874.00 3342.385380 SARIMA 1 2 Mtto. Contratos 2024-02-01 0.00 581.211202 SARIMA 2 2 Mtto. Contratos 2024-03-01 0.00 556.790898 SARIMA 3 2 Mtto. Contratos 2024-04-01 175.58 1232.007561 SARIMA 4 2 Mtto. Contratos 2024-05-01 175.58 1908.993900 SARIMA 5 2 Mtto. Contratos 2024-06-01 175.58 551.560840 SARIMA 6 2 Mtto. Contratos 2024-07-01 175.58 1307.669885 SARIMA 7 2 Mtto. Contratos 2024-08-01 175.58 542.239712 SARIMA 8 2 Mtto. Contratos 2024-09-01 175.58 839.468846 SARIMA 9 2 Mtto. Contratos 2024-10-01 175.58 1234.156158 SARIMA
/usr/local/lib/python3.12/dist-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals warnings.warn("Maximum Likelihood optimization failed to "
Bloque 4 · Unificación de predicciones y cálculo de métricas (MAE, MAPE_pct, WAPE, SMAPE_pct)¶
En este bloque:
Unificamos predicciones ETS y SARIMA.
Calculamos las 4 métricas por pareja y modelo usando las funciones del notebook.
Calculamos el resumen global por modelo.
# Unificamos predicciones (si no hay ETS, usamos solo SARIMA)
if "preds_ETS_2024" in globals() and not preds_ETS_2024.empty:
preds_all_2024 = pd.concat([preds_ETS_2024, preds_SARIMA_2024], ignore_index=True)
else:
preds_all_2024 = preds_SARIMA_2024.copy()
# Añadimos MASE a las métricas por pareja y modelo (usamos m=12 para naive estacional mensual)
# Requisitos: tener disponible pair_series() y df_train_fd1_v5_ITE1_SARIMA
assert "pair_series" in globals(), "Falta pair_series(); ejecútese el bloque donde la definimos."
assert "df_train_fd1_v5_ITE1_SARIMA" in globals(), "Falta df_train_fd1_v5_ITE1_SARIMA en memoria."
registros_metricas = []
for (b, t, m), g in preds_all_2024.groupby(["ID_BUILDING", "FM_COST_TYPE", "model"]):
y_true = g["y_real"].values
y_hat = g["y_hat"].values
# Obtenemos la serie de train para calcular MASE respecto a naive estacional
y_tr = pair_series(df_train_fd1_v5_ITE1_SARIMA, (b, t)).values
registros_metricas.append({
"ID_BUILDING": b,
"FM_COST_TYPE": t,
"model": m,
"MAE": mae(y_true, y_hat), # escala del dato
"MAPE_pct": mape(y_true, y_hat), # porcentaje según función del notebook
"WAPE": wape(y_true, y_hat), # 0-1 (ponderado por nivel)
"SMAPE_pct": smape(y_true, y_hat), # porcentaje según función del notebook
"MASE": mase(y_tr, y_true, y_hat, m=12), # respecto a naive estacional mensual
"n_obs": len(g)
})
df_metricas_2024 = (
pd.DataFrame(registros_metricas)
.sort_values(["model", "WAPE", "MAE"])
.reset_index(drop=True)
)
# Resumen global por modelo (colapsamos por meses y parejas)
# Para MASE_global usamos la media ponderada por n_obs de la MASE por pareja (no tiene sentido recalcular MASE global sin train por pareja)
resumen_global = []
for m, g in preds_all_2024.groupby("model"):
y_true = g["y_real"].values
y_hat = g["y_hat"].values
metricas_m = df_metricas_2024[df_metricas_2024["model"] == m]
peso = metricas_m["n_obs"].to_numpy()
mase_vals = metricas_m["MASE"].to_numpy()
valid = (~np.isnan(mase_vals)) & (peso > 0)
mase_global = np.nan if valid.sum() == 0 else np.nansum(mase_vals[valid] * peso[valid]) / np.sum(peso[valid])
resumen_global.append({
"model": m,
"MAE_global": mae(y_true, y_hat),
"MAPE_global_pct": mape(y_true, y_hat),
"WAPE_global": wape(y_true, y_hat),
"SMAPE_global_pct": smape(y_true, y_hat),
"MASE_global": mase_global,
"n_filas": len(g)
})
df_resumen_global_2024 = (
pd.DataFrame(resumen_global)
.sort_values("WAPE_global")
.reset_index(drop=True)
)
print("Predicciones unificadas:", preds_all_2024.shape)
print("Métricas por pareja y modelo:", df_metricas_2024.shape)
print("Resumen global por modelo:")
print(df_resumen_global_2024)
Predicciones unificadas: (11400, 6) Métricas por pareja y modelo: (950, 9) Resumen global por modelo: model MAE_global MAPE_global_pct WAPE_global SMAPE_global_pct \ 0 ETS 1338.020293 9526.005176 0.302550 51.835090 1 SARIMA 15658.454548 78220.399089 3.540659 56.277935 MASE_global n_filas 0 2.103296 5700 1 13.020476 5700
Bloque 5 · Resumen global por modelo y guardado en Drive¶
En este bloque:
Calculamos resumen global por modelo para MAE, MAPE_pct, WAPE, SMAPE_pct.
Calculamos MASE_global como media ponderada por n_obs de la MASE por pareja.
# Resumen global por modelo (agregamos sobre todas las parejas y meses)
resumen_global = []
for m, g in preds_all_2024.groupby("model"):
y_true = g["y_real"].values
y_hat = g["y_hat"].values
# Para MASE global usamos la media ponderada por n_obs sobre df_metricas_2024
metricas_m = df_metricas_2024[df_metricas_2024["model"] == m]
peso = metricas_m["n_obs"].values
mase_vals = metricas_m["MASE"].values
mase_global = np.nan if peso.sum() == 0 else np.nansum(mase_vals * peso) / np.sum(peso)
resumen_global.append({
"model": m,
"MAE_global": mae(y_true, y_hat),
"MAPE_global_pct": mape(y_true, y_hat),
"WAPE_global": wape(y_true, y_hat),
"SMAPE_global_pct": smape(y_true, y_hat),
"MASE_global": mase_global,
"n_filas": len(g)
})
df_resumen_global_2024 = pd.DataFrame(resumen_global).sort_values("WAPE_global").reset_index(drop=True)
print("Resumen global por modelo:")
print(df_resumen_global_2024)
Resumen global por modelo: model MAE_global MAPE_global_pct WAPE_global SMAPE_global_pct \ 0 ETS 1338.020293 9526.005176 0.302550 51.835090 1 SARIMA 15658.454548 78220.399089 3.540659 56.277935 MASE_global n_filas 0 2.103296 5700 1 13.020476 5700
Guardamos predicciones y métricas en CSV y Excel de ETS Estacional (ETS_S) y de SARIMA en Drive.¶
# Guardamos files con sufijo ETS_S (ETS estacional) y SARIMA.
# 1) Guardamos predicciones SARIMA y ETS_S con sus reales
# 2) Guardamos métricas filtradas por modelo (SARIMA y ETS) desde df_metricas_2024
# 3) Forzamos el sufijo ETS_S en los ficheros de salida para no confundir con EST
import os
# Definimos la carpeta base en Drive y nos aseguramos de que exista
base_dir = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
os.makedirs(base_dir, exist_ok=True)
# Comprobamos que tenemos en memoria las tablas esperadas
assert "df_metricas_2024" in globals(), "No encontramos df_metricas_2024"
# Filtramos métricas por modelo
metrics_sarima = df_metricas_2024[df_metricas_2024["model"] == "SARIMA"].copy()
metrics_ets_s = df_metricas_2024[df_metricas_2024["model"] == "ETS"].copy()
# Rutas de salida (mantenemos el estilo de nombres e imponemos ETS_S)
pred_sarima_path = os.path.join(base_dir, "df_pred_fd1_v5_ITE1_sarima.csv")
metrics_sarima_path= os.path.join(base_dir, "df_metrics_fd1_v5_ITE1_sarima.csv")
pred_ets_s_path = os.path.join(base_dir, "df_pred_fd1_v5_ITE1_ETS_S.csv")
metrics_ets_s_path = os.path.join(base_dir, "df_metrics_fd1_v5_ITE1_ETS_S.csv")
# Guardamos predicciones SARIMA (si existen)
if "preds_SARIMA_2024" in globals() and not preds_SARIMA_2024.empty:
preds_SARIMA_2024.to_csv(pred_sarima_path, index=False)
else:
print("Aviso: no encontramos preds_SARIMA_2024 o está vacío; no guardamos predicciones SARIMA.")
# Guardamos predicciones ETS_S (si existen)
if "preds_ETS_2024" in globals() and not preds_ETS_2024.empty:
preds_ETS_2024.to_csv(pred_ets_s_path, index=False)
else:
print("Aviso: no encontramos preds_ETS_2024 o está vacío; no guardamos predicciones ETS_S.")
# Guardamos métricas por modelo
metrics_sarima.to_csv(metrics_sarima_path, sep=";", index=False)
metrics_ets_s.to_csv(metrics_ets_s_path,sep=";", index=False)
# Mostramos confirmación de guardado
print("Guardados en Drive:")
if "preds_SARIMA_2024" in globals() and not preds_SARIMA_2024.empty:
print("Predicciones SARIMA:", pred_sarima_path)
else:
print("Predicciones SARIMA: no disponibles")
if "preds_ETS_2024" in globals() and not preds_ETS_2024.empty:
print("Predicciones ETS_S :", pred_ets_s_path)
else:
print("Predicciones ETS_S : no disponibles")
print("Métricas SARIMA :", metrics_sarima_path)
print("Métricas ETS_S :", metrics_ets_s_path)
Guardados en Drive: Predicciones SARIMA: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_pred_fd1_v5_ITE1_sarima.csv Predicciones ETS_S : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_pred_fd1_v5_ITE1_ETS_S.csv Métricas SARIMA : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_metrics_fd1_v5_ITE1_sarima.csv Métricas ETS_S : /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_metrics_fd1_v5_ITE1_ETS_S.csv
Interpretación¶
- ETS supera contundentemente a SARIMA en todas las métricas relevantes:
WAPE: ETS = 0,30 vs SARIMA = 3,54. En términos simples, con ETS el error absoluto agregado es ~30% del total real, mientras que con SARIMA asciende a ~354%. Es una diferencia abismal: SARIMA está, de media, más de 10× peor.
SMAPE: ETS = 51,8% vs SARIMA = 56,3%. También mejor ETS (menor es mejor), aunque ambos valores siguen siendo altos.
MASE: ETS = 2,10 vs SARIMA = 13,02. Recordatorio: MASE=1 equivale a igualar al naive estacional (m=12).
ETS rinde 2,1 veces peor que el naive estacional (serie 2024 igual a la del 2023).
SARIMA rinde 13 veces peor que el naive estacional.
Podemos confirmar en este caso también que el naive estacional es un baseline muy fuerte para estas series a nivel global; SARIMA queda muy lejos y ETS “no lo bate” de forma global.
- Por qué MAPE sale desorbitado
MAPE_global_pct: ETS ≈ 9.526% y SARIMA ≈ 78.220%. Estos porcentajes tan grandes no son informativos porque hay muchos meses con valores reales muy pequeños o cero, lo que infla el denominador y dispara el MAPE. Para este caso conviene no usar MAPE como métrica principal y centrarnos en WAPE/SMAPE/MASE.
- Qué nos dice cada métrica en conjunto
WAPE (0-1) nos da la foto “agregada” ponderada por el nivel de gasto: ETS ≈ 0,30 es aceptable; SARIMA ≈ 3,54 indica fallo sistémico.
SMAPE (%) es más robusta que el MAPE ante ceros: ETS gana, pero con ~52% sigue indicando mucha variabilidad/noise.
MASE revela el mensaje más importante: el naive estacional (m=12) es difícil de batir aquí. ETS mejora a SARIMA, pero aún queda por detrás del baseline.
Diagnóstico probable
Series cortas/heterogéneas y con muchos ceros: SARIMA necesita más datos y estabilidad; en estas condiciones tiende a sobre/infra-ajustar y a veces genera negativos o trayectorias erráticas.
La especificación de SARIMA (incluso con rejilla ligera o versión rápida) no encaja bien en un conjunto tan diverso; el coste de seleccionar órdenes óptimos por pareja es alto y, aun así, los ceros intermitentes penalizan.
ETS aditivo es más robusto ante ceros que un SARIMA mal especificado, pero no supera al naive estacional de forma global, lo que sugiere estacionalidad fuerte y poco patrón intra-anual adicional aprovechable.
Conclusiones para este sub_dataframe _SARIMA:
Adoptar ETS estacional como default frente a SARIMA para estas parejas.
Introducir el baseline “Naive estacional (m=12)” en la comparativa y seleccionar por pareja el campeón entre: Naive estacional vs ETS (y SARIMA solo donde muestre clara mejora).
Para series intermitentes (alto % de ceros), probar Croston/SBA; suelen funcionar mejor que ETS/SARIMA cuando hay muchos meses en cero.
Realizar una higiene de las predicción: truncar a no-negativos las predicciones (p. ej. max(y_hat, 0)) antes de computar métricas; ayuda cuando los modelos generan valores negativos irreales.
Métricas operativas: priorizar WAPE y MASE (y mirar medianas además de medias para no dejar que unos pocos casos extremos distorsionen la lectura global).
En resumen: ETS gana claramente a SARIMA en tu 2024, pero ninguno bate al naive estacional en promedio. El siguiente paso natural es un selector por pareja entre Naive estacional, ETS y (según caso) Croston, y usar WAPE/MASE como criterio principal.
Hasta aquí nuestra primera clasificación. Pasamos a finalizar con el sub_dataframe _HOLT y revisamos situación de todos los sub_dataframes modelizados.
MODELIZACION HOLT¶
Definimos función genérica para carga genérica de TRAIN y TEST desde Drive (válido para _SARIMA, _ARIMA_filtered, _ETS_S, etc.)¶
En este bloque:
- Definimos OUT_DIR y el TAG (sufijo del fichero).
- Intentamos cargar primero desde Excel; si no existe, probamos CSV con separador ';'.
- Normalizamos la columna FECHA a datetime.
- Registramos los dataframes en globals() con el nombre df_train_fd1_v5_ITE1_{TAG} y df_test_fd1_v5_ITE1_{TAG}.
# Definimos carpeta y sufijo a cargar (cambiar TAG según el sub_dataframe)
OUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
TAG = "HOLT" # ejemplos: "SARIMA", "ARIMA_filtered", "ETS_S"
# Rutas de ficheros esperados
train_xlsx = os.path.join(OUT_DIR, f"df_train_fd1_v5_ITE1_{TAG}.xlsx")
test_xlsx = os.path.join(OUT_DIR, f"df_test_fd1_v5_ITE1_{TAG}.xlsx")
train_csv = os.path.join(OUT_DIR, f"df_train_fd1_v5_ITE1_{TAG}.csv")
test_csv = os.path.join(OUT_DIR, f"df_test_fd1_v5_ITE1_{TAG}.csv")
def _load_df(xlsx_path, csv_path):
# Intentamos Excel y, si no existe, probamos CSV con sep=';'
if os.path.exists(xlsx_path):
df = pd.read_excel(xlsx_path)
origen = xlsx_path
elif os.path.exists(csv_path):
df = pd.read_csv(csv_path, sep=";")
origen = csv_path
else:
raise FileNotFoundError(f"No encontramos ni Excel ni CSV:\n - {xlsx_path}\n - {csv_path}")
# Normalizamos FECHA si existe
if "FECHA" in df.columns:
df["FECHA"] = pd.to_datetime(df["FECHA"], errors="coerce")
return df, origen
# Cargamos TRAIN y TEST
df_train, origen_train = _load_df(train_xlsx, train_csv)
df_test, origen_test = _load_df(test_xlsx, test_csv)
print("Cargado TRAIN desde:", origen_train, "| Shape:", df_train.shape)
print("Cargado TEST desde:", origen_test, "| Shape:", df_test.shape)
# Registramos con nombre estándar en globals() para mantener la convención del proyecto
globals()[f"df_train_fd1_v5_ITE1_{TAG}"] = df_train
globals()[f"df_test_fd1_v5_ITE1_{TAG}"] = df_test
# Mostramos una vista rápida
print("\nTRAIN.head():")
print(df_train.head(3))
print("\nTEST.head():")
print(df_test.head(3))
print("\nTRAIN.shape:")
print(df_train.shape)
print("\nTEST.shape:")
print(df_test.shape)
Cargado TRAIN desde: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_train_fd1_v5_ITE1_HOLT.xlsx | Shape: (899, 14) Cargado TEST desde: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/df_test_fd1_v5_ITE1_HOLT.xlsx | Shape: (368, 14) TRAIN.head(): ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 121 Mtto. Correctivo 2021-01-01 2021 1 663.69 1 121 Mtto. Correctivo 2021-02-01 2021 2 549.00 2 121 Mtto. Correctivo 2021-03-01 2021 3 1349.58 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD \ 0 España 17 Bingo INTERNO Mantenimiento 1 España 17 Bingo INTERNO Mantenimiento 2 España 17 Bingo INTERNO Mantenimiento diagnosis model_suggested model_group 0 tendencia Holt(ETS)|ARIMA_drift HOLT 1 tendencia Holt(ETS)|ARIMA_drift HOLT 2 tendencia Holt(ETS)|ARIMA_drift HOLT TEST.head(): ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 121 Mtto. Correctivo 2024-01-01 2024 1 140.42 1 121 Mtto. Correctivo 2024-02-01 2024 2 407.78 2 121 Mtto. Correctivo 2024-03-01 2024 3 1419.69 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD \ 0 España 17 Bingo INTERNO Mantenimiento 1 España 17 Bingo INTERNO Mantenimiento 2 España 17 Bingo INTERNO Mantenimiento diagnosis model_suggested model_group 0 tendencia Holt(ETS)|ARIMA_drift HOLT 1 tendencia Holt(ETS)|ARIMA_drift HOLT 2 tendencia Holt(ETS)|ARIMA_drift HOLT TRAIN.shape: (899, 14) TEST.shape: (368, 14)
Entrenamos el train, realizamos previsiones y calculamos métricas de validación.¶
Como siguiente paso, vamos a proceder con el entrenamiento vía Holt (ETS sin estacionalidad, con tendencia) por pareja y realizar las previsiones de 2024.
Para cada (ID_BUILDING
, FM_COST_TYPE
):
Entrenamos ExponentialSmoothing
con trend
='add' y damped trend
.
Si la serie es muy corta (n < 3), caemos a
SimpleExpSmoothing
;si n < 2, usamos
naive último valor
.
Pronosticamos exactamente el número de meses que haya en TEST para esa pareja.
Calculamos métricas por pareja y un resumen global: MAE, WAPE, SMAPE (y opcional MASE).
Devolvemos dos dataframes: preds_holt (detalle por mes) y metricas_holt (por pareja + global).
# ------------------------------------------------------------
# 1) Función de pronóstico HOLT con fallbacks mínimos
# ------------------------------------------------------------
def forecast_holt(y_train: pd.Series, h: int) -> np.ndarray:
"""
y_train: Serie mensual (índice 'MS'), float.
h: pasos a predecir (nº meses en el test de esa pareja)
"""
n = len(y_train)
if n == 0:
return np.zeros(h, dtype=float)
# Fallbacks para series cortas
if n < 2:
last = float(y_train.iloc[-1])
return np.repeat(last, h)
if n == 2:
try:
model = SimpleExpSmoothing(y_train, initialization_method="estimated").fit(optimized=True)
return model.forecast(h)
except Exception:
last = float(y_train.iloc[-1])
return np.repeat(last, h)
# Holt con tendencia amortiguada (sin estacionalidad)
try:
model = ExponentialSmoothing(
y_train,
trend="add",
damped_trend=True,
seasonal=None,
initialization_method="estimated",
).fit(optimized=True)
return model.forecast(h)
except Exception:
# Último fallback robusto
try:
model = SimpleExpSmoothing(y_train, initialization_method="estimated").fit(optimized=True)
return model.forecast(h)
except Exception:
last = float(y_train.iloc[-1])
return np.repeat(last, h)
# ------------------------------------------------------------
# 2) Preparación ligera y pares a evaluar (usando df_train / df_test)
# ------------------------------------------------------------
# Aseguramos tipos de fecha y orden
df_train = df_train.copy()
df_test = df_test.copy()
df_train["FECHA"] = pd.to_datetime(df_train["FECHA"])
df_test["FECHA"] = pd.to_datetime(df_test["FECHA"])
df_train.sort_values(["ID_BUILDING","FM_COST_TYPE", "FECHA"], inplace=True)
df_test.sort_values (["ID_BUILDING","FM_COST_TYPE", "FECHA"], inplace=True)
# Sólo iteramos por parejas presentes en train y test
pairs_train = df_train[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_test = df_test[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs = pairs_train.merge(pairs_test, on=["ID_BUILDING","FM_COST_TYPE"])
# ------------------------------------------------------------
# 3) Loop principal por pareja
# ------------------------------------------------------------
preds_rows = []
metricas_rows = []
for bid, cty in pairs.itertuples(index=False, name=None):
# Serie mensual continua train/test usando tus helpers
y_tr = pair_series(df_train, (bid, cty)) # Serie mensual (índice MS), float
y_te = pair_series(df_test, (bid, cty)) # Índice de meses reales y y_true
if y_tr.empty or y_te.empty:
continue
h = len(y_te)
y_hat = forecast_holt(y_tr, h)
# Alineamos a las fechas de test
df_pair_pred = pd.DataFrame({
"ID_BUILDING": bid,
"FM_COST_TYPE": cty,
"FECHA": y_te.index, # índice mensual del test
"y_real": y_te.values.astype(float),
"y_hat": np.asarray(y_hat, dtype=float),
"model": "Holt(ETS)_damped"
})
preds_rows.append(df_pair_pred)
# Métricas por pareja (reutilizamos tus funciones)
y_true = df_pair_pred["y_real"].values
y_pred = df_pair_pred["y_hat"].values
metricas_rows.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": cty,
"n_train": len(y_tr),
"n_test": h,
"MAE": mae(y_true, y_pred),
"MAPE_pct": mape(y_true, y_pred),
"WAPE": wape(y_true, y_pred),
"SMAPE_pct": smape(y_true, y_pred),
"MASE1": mase_no_fallback(y_tr.values, y_true, y_pred, m=1), # baseline naive simple
"MASE12": mase12(y_tr.values, y_true, y_pred), # baseline naive estacional mensual
"model": "Holt(ETS)_damped"
})
# ------------------------------------------------------------
# 4) Salidas finales
# ------------------------------------------------------------
preds_holt = pd.concat(preds_rows, ignore_index=True) if preds_rows else pd.DataFrame(
columns=["ID_BUILDING","FM_COST_TYPE","FECHA","y_real","y_hat","model"]
)
metricas_holt = pd.DataFrame(metricas_rows)
# Resumen global (WAPE ya es ponderado por suma de |y|)
if not preds_holt.empty:
y_all = preds_holt["y_real"].values
f_all = preds_holt["y_hat"].values
resumen_global = {
"model": "Holt(ETS)_damped",
"MAE_global": mae(y_all, f_all),
"MAPE_global_pct": mape(y_all, f_all),
"WAPE_global": wape(y_all, f_all),
"SMAPE_global_pct": smape(y_all, f_all),
"MASE1_global_mean": float(np.nanmean(metricas_holt["MASE1"])) if not metricas_holt.empty else np.nan,
"MASE12_global_mean": float(np.nanmean(metricas_holt["MASE12"])) if not metricas_holt.empty else np.nan
}
else:
resumen_global = {
"model": "Holt(ETS)_damped",
"MAE_global": np.nan,
"MAPE_global_pct": np.nan,
"WAPE_global": np.nan,
"SMAPE_global_pct": np.nan,
"MASE1_global_mean": np.nan,
"MASE12_global_mean": np.nan
}
print("Resumen global HOLT:", resumen_global)
# === Tabla de resumen global (1 fila) ===
resumen_cols = ["model","MAE_global","MAPE_global_pct","WAPE_global","SMAPE_global_pct","MASE1_global_mean", "MASE12_global_mean"]
resumen_df = pd.DataFrame([resumen_global])[resumen_cols]
# Formateo bonito (2 decimales en %)
resumen_fmt = resumen_df.copy()
for c in ["MAPE_global_pct","SMAPE_global_pct"]:
resumen_fmt[c] = resumen_fmt[c].map(lambda x: f"{x:.2f}%" if pd.notnull(x) else "NaN")
for c in ["MAE_global","WAPE_global","MASE1_global_mean", "MASE12_global_mean"]:
resumen_fmt[c] = resumen_fmt[c].map(lambda x: f"{x:.4f}" if pd.notnull(x) else "NaN")
display(resumen_fmt) # En Jupyter/Colab se verá como tabla
Resumen global HOLT: {'model': 'Holt(ETS)_damped', 'MAE_global': np.float64(867.3833794374615), 'MAPE_global_pct': np.float64(2418.3251645925106), 'WAPE_global': np.float64(0.5507451663956814), 'SMAPE_global_pct': np.float64(55.610707459262244), 'MASE1_global_mean': 1.9531936831932684, 'MASE12_global_mean': 1.082819284692108}
model | MAE_global | MAPE_global_pct | WAPE_global | SMAPE_global_pct | MASE1_global_mean | MASE12_global_mean | |
---|---|---|---|---|---|---|---|
0 | Holt(ETS)_damped | 867.3834 | 2418.33% | 0.5507 | 55.61% | 1.9532 | 1.0828 |
Realizamos un Top 10 mejores y Bottom 10 peores.
# --- TOP y BOTTOM 10 por WAPE ---
if not metricas_holt.empty:
# Ordenamos
top10 = metricas_holt.sort_values("WAPE").head(10).copy()
bot10 = metricas_holt.sort_values("WAPE").tail(10).copy()
def _fmt(df):
df2 = df[[
"ID_BUILDING","FM_COST_TYPE","n_train","n_test",
"MAE","MAPE_pct","WAPE","SMAPE_pct","MASE1","MASE12","model"
]].copy()
# Formateo
df2["MAPE_pct"] = df2["MAPE_pct"].map(lambda x: f"{x:.2f}%" if pd.notnull(x) else "NaN")
df2["SMAPE_pct"] = df2["SMAPE_pct"].map(lambda x: f"{x:.2f}%" if pd.notnull(x) else "NaN")
for c in ["MAE","WAPE","MASE1","MASE12"]:
df2[c] = df2[c].map(lambda x: f"{x:.4f}" if pd.notnull(x) else "NaN")
return df2
print("\n=== TOP 10 (mejor WAPE) ===")
print(_fmt(top10).to_string(index=False))
print("\n=== BOTTOM 10 (peor WAPE) ===")
print(_fmt(bot10).to_string(index=False))
else:
print("metricas_holt está vacío; no hay tablas por pareja que mostrar.")
=== TOP 10 (mejor WAPE) === ID_BUILDING FM_COST_TYPE n_train n_test MAE MAPE_pct WAPE SMAPE_pct MASE1 MASE12 model 1078 Eficiencia Energética 18 12 10.5190 4.58% 0.0463 4.71% 0.3727 0.0616 Holt(ETS)_damped 1001207 Servicios Ctto. 36 12 323.3000 5.96% 0.0595 6.06% 2.1772 0.1541 Holt(ETS)_damped 1099 Eficiencia Energética 18 12 14.8568 6.43% 0.0640 6.30% 0.4622 0.0789 Holt(ETS)_damped 1091 Eficiencia Energética 18 12 23.8365 8.77% 0.0872 8.55% 0.6358 0.1045 Holt(ETS)_damped 1079 Eficiencia Energética 18 12 27.1089 10.46% 0.1048 11.05% 0.8309 0.1314 Holt(ETS)_damped 1086 Eficiencia Energética 18 12 22.6732 10.48% 0.1050 11.08% 0.8603 0.1384 Holt(ETS)_damped 1000632 Eficiencia Energética 18 12 18.8121 11.08% 0.1111 11.75% 0.7548 0.1107 Holt(ETS)_damped 1080 Eficiencia Energética 18 12 10.8663 13.13% 0.1322 14.13% 0.7713 0.1059 Holt(ETS)_damped 1000872 Mtto. Contratos 33 12 52.2789 14.41% 0.1441 13.21% 5.6797 0.4102 Holt(ETS)_damped 1001208 Servicios Extra 34 12 641.9988 15.55% 0.1493 14.78% 1.1185 0.6240 Holt(ETS)_damped === BOTTOM 10 (peor WAPE) === ID_BUILDING FM_COST_TYPE n_train n_test MAE MAPE_pct WAPE SMAPE_pct MASE1 MASE12 model 1000437 Servicios Extra 34 12 1995.1925 236.95% 0.8555 89.33% 2.2467 2.4625 Holt(ETS)_damped 1000587 Mtto. Correctivo 33 9 335.2452 13389.23% 1.1477 95.04% 1.5610 1.0620 Holt(ETS)_damped 150 Mtto. Correctivo 34 12 1398.1271 20830.73% 1.2976 94.94% 3.8533 3.0593 Holt(ETS)_damped 1001339 Eficiencia Energética 18 12 487.1168 137.95% 1.4175 176.97% 0.7121 0.4424 Holt(ETS)_damped 124 Mtto. Correctivo 36 12 1207.3085 13073.95% 1.9500 121.53% 2.3830 2.2572 Holt(ETS)_damped 1000481 Mtto. Contratos 36 12 1379.8152 201.02% 1.9764 97.24% 7.9489 4.2488 Holt(ETS)_damped 1242 Mtto. Correctivo 36 12 471.6437 6824.00% 2.3121 125.31% 1.6858 1.3818 Holt(ETS)_damped 141 Mtto. Correctivo 36 12 1580.5699 21008.99% 2.3635 135.75% 2.4908 2.6915 Holt(ETS)_damped 131 Mtto. Correctivo 36 12 1502.6033 595.10% 2.4494 118.66% 2.1231 1.9583 Holt(ETS)_damped 121 Mtto. Correctivo 36 12 2319.8337 459.61% 2.6402 119.02% 2.4434 2.4216 Holt(ETS)_damped
Interpretación¶
El MAPE altísimo confirma que muchos valores reales son muy pequeños → cualquier error se dispara en %.
El WAPE ≈ 0.55 nos dice que el error es del 55% del gasto total → bastante elevado.
MASE1 ≈ 1.95 (peor que Naive simple).
MASE12 ≈ 1.08 (similar a Naive estacional).
TOP 10 (mejor WAPE con HOLT)
Predominan casos de Eficiencia Energética y algunos Servicios.
- WAPE muy bajo (≈ 5%–15%), con SMAPE también razonable (≈ 5%–15%).
Aquí HOLT funciona muy bien: es competitivo frente a Naive estacional.
BOTTOM 10 (peor WAPE con HOLT)
Casi todos son Mtto. Correctivo o Contratos.
WAPE > 1 (es decir, el error es mayor que el gasto real).
MAPE desorbitado (miles %) y SMAPE > 90%.
HOLT no captura nada útil: peor que los benchmarks.
Conclusiones:
Para cuentas estables/estacionales (ej. Eficiencia Energética), HOLT es razonable.
Para cuentas irregulares (ej. Correctivos), necesitamos modelos distintos: Croston, SBA, o enfoques ML con covariables.
import matplotlib.pyplot as plt
# --- Elegimos un TOP y un BOTTOM ---
best_pair = metricas_holt.sort_values("WAPE").iloc[0] # mejor WAPE
worst_pair = metricas_holt.sort_values("WAPE").iloc[-1] # peor WAPE
def plot_pair(pair_row, preds_df):
bid, cty = pair_row["ID_BUILDING"], pair_row["FM_COST_TYPE"]
dfp = preds_df[(preds_df.ID_BUILDING==bid) & (preds_df.FM_COST_TYPE==cty)].copy()
dfp = dfp.sort_values("FECHA")
plt.figure(figsize=(10,5))
plt.plot(dfp["FECHA"], dfp["y_real"], marker="o", label="Real")
plt.plot(dfp["FECHA"], dfp["y_hat"], marker="x", label="Predicho (HOLT)")
plt.title(f"ID_BUILDING={bid} | {cty}")
plt.xlabel("Fecha")
plt.ylabel("Coste")
plt.legend()
plt.grid(True)
plt.show()
# --- Gráficas ---
print("=== Caso TOP (mejor WAPE) ===")
plot_pair(best_pair, preds_holt)
print("=== Caso BOTTOM (peor WAPE) ===")
plot_pair(worst_pair, preds_holt)
=== Caso TOP (mejor WAPE) ===
=== Caso BOTTOM (peor WAPE) ===
Interpretación tabla Top 10¶
Aunque en la tabla el WAPE era bajo, gráficamente se nota que el Holt está “tirando hacia abajo” con una tendencia amortiguada y no replica el patrón real (que es casi constante con un salto puntual).
Nos pasa porque Holt con tendencia amortiguada siempre fuerza una pendiente (aunque sea pequeña).
En series casi planas o con saltos discretos, tiende a “arrastrar” la recta y no clava el valor constante.
Los indicadores como WAPE pueden salir bajos si los valores son grandes y los errores relativos pequeños… aunque visualmente se note mucho.
Para detectarlo podemos:
Revisar gráficos siempre: no sólo métricas → porque hay errores estructurales que no aparecen en los promedios.
Complementar con métricas adicionales:
bias = mean(y_pred - y_true) -> si hay sesgo sistemático hacia arriba o hacia abajo.
R^2 o correlación -> para ver si sigue la forma de la serie.
- Comparar las previsiones con otras y hacer benchmarks:
En este caso particular, el Naive estacional (m=12) o el Naive simple hubieran predicho los valores planos casi exactos.
Futuras posibles acciones:
Quitar el componente de tendencia → usar
SimpleExpSmoothing
en lugar de Holt para series como Eficiencia Energética, que son casi planas.Clasificación previa de series:
Si la diagnosis es plana/constante → aplicar SES o Naive.
Si es tendencial → Holt.
Si es estacional → ETS con estacionalidad.
Introducir reglas de fallback por diagnóstico en lugar de aplicar Holt a todo.
Estrategia 2 - Analisis serie x serie¶
Cargamos dataframes de los conjuntos train y test de la ITE1¶
# Cargamos datasets df_fd1_v5_ITE1 y df_fd1_v5_Real_ITE1_2024
# Ruta base donde guardaste los outputs
ruta_base = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/"
# Dataset de entrenamiento (Iteración 1: 2021-2023)
df_fd1_v5_ITE1 = pd.read_csv(ruta_base + "df_fd1_v5_ITE1.csv", sep=";")
# Dataset de reales (Iteración 1: datos del 2024)
df_fd1_v5_Real_ITE1_2024 = pd.read_csv(ruta_base + "df_fd1_v5_Real_ITE1_2024.csv", sep=";")
# Confirmamos tamaños y columnas
print("ITE1 shape:", df_fd1_v5_ITE1.shape)
print("Real_ITE1_2024 shape:", df_fd1_v5_Real_ITE1_2024.shape)
print("\nColumnas ITE1:", df_fd1_v5_ITE1.columns.tolist())
print("Columnas Real_ITE1_2024:", df_fd1_v5_Real_ITE1_2024.columns.tolist())
ITE1 shape: (174626, 12) Real_ITE1_2024 shape: (69290, 12) Columnas ITE1: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF'] Columnas Real_ITE1_2024: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF']
Preparamos conjunto de _test y _train para cada dataframe con agrupación mensualizada de los costes para cada pareja ID_BUILGING - FM_COST_TYPE.
Definimos las funciones necesarias¶
# ===============================================================
# Helper 0) Funciones auxiliares
# ===============================================================
def crear_fecha(df):
"""
Creamos la columna FECHA como primer día de mes a partir de YEAR y MONTH.
Controlamos tipos y meses válidos.
"""
df = df.copy()
df["YEAR"] = pd.to_numeric(df["YEAR"], errors="coerce").astype("Int64")
df["MONTH"] = pd.to_numeric(df["MONTH"], errors="coerce").astype("Int64")
df = df[(df["MONTH"] >= 1) & (df["MONTH"] <= 12)]
df["FECHA"] = pd.to_datetime(
dict(year=df["YEAR"].astype(int), month=df["MONTH"].astype(int), day=1),
errors="coerce"
)
return df
def serie_mensual_con_huecos(df, year_ini, year_fin):
"""
A partir del detalle (varias líneas por mes), agregamos por pareja y FECHA.
Devolvemos la serie mensual agregada (suma de cost_float_mod) con los meses observados.
"""
df = df.copy()
df = df[(df["FECHA"] >= f"{year_ini}-01-01") & (df["FECHA"] <= f"{year_fin}-12-31")]
agg = (df.groupby(["ID_BUILDING","FM_COST_TYPE","FECHA"], as_index=False)["cost_float_mod"]
.sum())
return agg
def completar_meses_por_pareja(agg_df):
"""
Para cada pareja (ID_BUILDING, FM_COST_TYPE) reindexamos entre su primer y último mes observado
y rellenamos los meses ausentes con 0. No extendemos fuera del rango observado de esa pareja.
"""
pares = agg_df[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
out = []
# Recorremos cada pareja y reindexamos su rango observado
for _, row in pares.iterrows():
bid, ctype = row["ID_BUILDING"], row["FM_COST_TYPE"]
sub = agg_df[(agg_df["ID_BUILDING"]==bid) & (agg_df["FM_COST_TYPE"]==ctype)].copy()
sub = sub.set_index("FECHA").sort_index()
# Rango observado de esa pareja
fmin, fmax = sub.index.min(), sub.index.max()
# Reindexación a frecuencia mensual únicamente entre fmin y fmax
idx = pd.date_range(fmin, fmax, freq="MS")
sub = sub.reindex(idx)
sub["ID_BUILDING"] = bid
sub["FM_COST_TYPE"] = ctype
# Rellenamos NaN de coste con 0
sub["cost_float_mod"] = sub["cost_float_mod"].fillna(0.0)
sub = sub.reset_index().rename(columns={"index":"FECHA"})
out.append(sub)
out = pd.concat(out, ignore_index=True)
# Ordenamos
out = out[["ID_BUILDING","FM_COST_TYPE","FECHA","cost_float_mod"]].sort_values(
["ID_BUILDING","FM_COST_TYPE","FECHA"]
).reset_index(drop=True)
return out
def moda_segura(s):
"""
Calculamos el modo (valor más frecuente) de una serie, devolviendo el primero en caso de empate.
Si todo es NaN, devolvemos NaN.
"""
s = s.dropna()
if s.empty:
return np.nan
m = s.mode()
return m.iloc[0] if len(m) > 0 else np.nan
def contexto_por_pareja(df_base_2021_2023):
"""
A partir del detalle 2021–2023, calculamos el modo por pareja de las variables de contexto.
"""
cols_ctx = ["COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"]
grp = df_base_2021_2023.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False)
ctx = grp.agg({col: moda_segura for col in cols_ctx})
return ctx
def ensamblar_final(serie_completa, ctx):
"""
Unimos la serie mensual completa con el contexto por pareja y añadimos YEAR y MONTH.
Validamos duplicados.
"""
df = serie_completa.merge(ctx, on=["ID_BUILDING","FM_COST_TYPE"], how="left")
df["YEAR"] = df["FECHA"].dt.year.astype(int)
df["MONTH"] = df["FECHA"].dt.month.astype(int)
# Validamos unicidad por pareja y FECHA
dup_mask = df.duplicated(subset=["ID_BUILDING","FM_COST_TYPE","FECHA"], keep=False)
assert not dup_mask.any(), "Hay duplicados por pareja y FECHA tras el ensamblado."
# Orden práctico de columnas
ordered_cols = [
"ID_BUILDING","FM_COST_TYPE","FECHA","YEAR","MONTH","cost_float_mod",
"COUNTRY_DEF","ID_REGION_GRUPO","TIPO_USO","SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD"
]
# Algunas columnas de contexto pueden no existir si venían vacías; controlamos
for c in ordered_cols:
if c not in df.columns:
df[c] = np.nan
df = df[ordered_cols].sort_values(["ID_BUILDING","FM_COST_TYPE","FECHA"]).reset_index(drop=True)
return df
def chequeo_rapido(nombre, df, anio_ini, anio_fin, n_muestra=5):
"""
Hacemos un chequeo de forma, rango temporal y mostramos una pequeña muestra para validar.
"""
print(f"=== {nombre} ===")
print("Shape:", df.shape)
print("FECHA min:", df["FECHA"].min(), "| FECHA max:", df["FECHA"].max())
# Comprobamos rango esperado
assert df["FECHA"].min() >= pd.to_datetime(f"{anio_ini}-01-01"), "FECHA mínima fuera de rango"
assert df["FECHA"].max() <= pd.to_datetime(f"{anio_fin}-12-31"), "FECHA máxima fuera de rango"
# Duplicados
dups = df.duplicated(subset=["ID_BUILDING","FM_COST_TYPE","FECHA"]).sum()
print("Duplicados por pareja/FECHA:", dups)
# Muestra
print("\nMuestra de filas:")
print(df.head(n_muestra))
print("="*60, "\n")
Ajustamos los conjuntos de la ITE1 agrupando por meses¶
# ===============================================================
# Secuencia ITE1) Generamos df_fd1_v5_ITE1_train y df_fd1_v5_ITE1_test
# Partimos de:
# - df_fd1_v5_ITE1 (detalle 2021–2023)
# - df_fd1_v5_Real_ITE1_2024 (detalle 2024)
# ===============================================================
# 1) Creamos FECHA en ambos orígenes
df_ite1_detalle_train = crear_fecha(df_fd1_v5_ITE1)
df_ite1_detalle_test = crear_fecha(df_fd1_v5_Real_ITE1_2024)
# 2) Filtramos por YEAR explícitamente
df_ite1_detalle_train = df_ite1_detalle_train[(df_ite1_detalle_train["YEAR"]>=2021) & (df_ite1_detalle_train["YEAR"]<=2023)]
df_ite1_detalle_test = df_ite1_detalle_test[(df_ite1_detalle_test["YEAR"]==2024)]
# 3) Agregamos a nivel mensual por pareja (suma de cost_float_mod)
agg_train = serie_mensual_con_huecos(df_ite1_detalle_train, 2021, 2023)
agg_test = serie_mensual_con_huecos(df_ite1_detalle_test, 2024, 2024)
# 4) Rellenamos meses faltantes entre el primer y último mes observado de cada pareja
serie_train_completa = completar_meses_por_pareja(agg_train)
serie_test_completa = completar_meses_por_pareja(agg_test)
# 5) Calculamos contexto por pareja usando SOLO 2021–2023 (moda)
ctx_2021_2023 = contexto_por_pareja(df_ite1_detalle_train)
# 6) Ensamblamos finales con contexto y añadimos YEAR/MONTH
df_fd1_v5_ITE1_train = ensamblar_final(serie_train_completa, ctx_2021_2023)
df_fd1_v5_ITE1_test = ensamblar_final(serie_test_completa, ctx_2021_2023) # usamos el mismo contexto
# 7) Comprobaciones rápidas
chequeo_rapido("df_fd1_v5_ITE1_train (2021–2023)", df_fd1_v5_ITE1_train, 2021, 2023)
chequeo_rapido("df_fd1_v5_ITE1_test (2024)", df_fd1_v5_ITE1_test, 2024, 2024)
=== df_fd1_v5_ITE1_train (2021–2023) === Shape: (65466, 11) FECHA min: 2021-01-01 00:00:00 | FECHA max: 2023-12-01 00:00:00 Duplicados por pareja/FECHA: 0 Muestra de filas: ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Eficiencia Energética 2021-12-01 2021 12 0.00 1 2 Licencias 2021-01-01 2021 1 1145.46 2 2 Licencias 2021-02-01 2021 2 55.95 3 2 Licencias 2021-03-01 2021 3 0.00 4 2 Licencias 2021-04-01 2021 4 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 \ 0 España 2 Oficinas INTERNO 1 España 2 Oficinas INTERNO 2 España 2 Oficinas INTERNO 3 España 2 Oficinas INTERNO 4 España 2 Oficinas INTERNO FM_RESPONSIBLE_MOD 0 Eficiencia Energética 1 Licencias 2 Licencias 3 Licencias 4 Licencias ============================================================ === df_fd1_v5_ITE1_test (2024) === Shape: (23750, 11) FECHA min: 2024-01-01 00:00:00 | FECHA max: 2024-12-01 00:00:00 Duplicados por pareja/FECHA: 0 Muestra de filas: ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2024-12-01 2024 12 202.80 1 2 Mtto. Contratos 2024-01-01 2024 1 1874.00 2 2 Mtto. Contratos 2024-02-01 2024 2 0.00 3 2 Mtto. Contratos 2024-03-01 2024 3 0.00 4 2 Mtto. Contratos 2024-04-01 2024 4 175.58 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD 0 España 2 Oficinas INTERNO Licencias 1 España 2 Oficinas EXTERNO Mantenimiento 2 España 2 Oficinas EXTERNO Mantenimiento 3 España 2 Oficinas EXTERNO Mantenimiento 4 España 2 Oficinas EXTERNO Mantenimiento ============================================================
Vamosa a obtener la serie de una pareja ID_BUILDING - FM_COST_TYPE
# 1) Definimos los dataframes de trabajo
# Filtramos por rango por seguridad utilizando YEAR (ya viene en los agregados)
train_df = df_fd1_v5_ITE1_train[
(df_fd1_v5_ITE1_train["YEAR"] >= 2021) & (df_fd1_v5_ITE1_train["YEAR"] <= 2023)
].copy()
real_df = df_fd1_v5_ITE1_test[
(df_fd1_v5_ITE1_test["YEAR"] == 2024)
].copy()
# 2) Comprobamos columnas mínimas
cols_req = ["ID_BUILDING","FM_COST_TYPE","FECHA","cost_float_mod"]
for name, df in [("train_df", train_df), ("real_df", real_df)]:
faltan = set(cols_req) - set(df.columns)
assert not faltan, f"Faltan columnas en {name}: {faltan}"
# 3) Identificamos parejas comunes entre ambos conjuntos
pairs_train = train_df[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_real = real_df[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_both = pairs_train.merge(pairs_real, on=["ID_BUILDING","FM_COST_TYPE"])
assert len(pairs_both) > 0, "No hay parejas en común entre 2021-2023 y 2024."
# 4) Elegimos una pareja aleatoria presente en ambos (si queremos reproducibilidad, fijamos random_state)
# pair = pairs_both.sample(1, random_state=None).iloc[0]
# bid, ctype = pair["ID_BUILDING"], pair["FM_COST_TYPE"]
bid, ctype = 1000480, "Mtto. Correctivo" # para ser coherente con el resto del notebook lo fijamos
print(f"Pareja seleccionada -> ID_BUILDING: {bid} | FM_COST_TYPE: {ctype}")
# 5) Extraemos las dos series (train y real) para esa pareja
# Como ya están agregadas por mes, cada FECHA es única por pareja.
s_train = (train_df[(train_df["ID_BUILDING"]==bid) & (train_df["FM_COST_TYPE"]==ctype)]
.loc[:, ["FECHA","cost_float_mod"]]
.rename(columns={"cost_float_mod":"valor"})
.sort_values("FECHA")
.reset_index(drop=True))
s_real = (real_df[(real_df["ID_BUILDING"]==bid) & (real_df["FM_COST_TYPE"]==ctype)]
.loc[:, ["FECHA","cost_float_mod"]]
.rename(columns={"cost_float_mod":"valor"})
.sort_values("FECHA")
.reset_index(drop=True))
# 6) Graficamos cada serie por separado para una lectura clara
plt.figure()
plt.plot(s_train["FECHA"], s_train["valor"])
plt.title(f"Serie 2021–2023 | ID {bid} · {ctype}")
plt.xlabel("Fecha"); plt.ylabel("Valor")
plt.tight_layout()
plt.show()
plt.figure()
plt.plot(s_real["FECHA"], s_real["valor"])
plt.title(f"Serie 2024 (Real) | ID {bid} · {ctype}")
plt.xlabel("Fecha"); plt.ylabel("Valor")
plt.tight_layout()
plt.show()
# 7) Preparamos salida en CSV simple
def to_simple_csv(df):
out = df.copy()
out["FECHA"] = out["FECHA"].dt.strftime("%Y-%m-%d")
return out.to_csv(index=False)
print("\n=== SERIE ENTRENAMIENTO 2021-2023 ===")
print(to_simple_csv(s_train))
print("\n=== SERIE REAL 2024 ===")
print(to_simple_csv(s_real))
Pareja seleccionada -> ID_BUILDING: 1000480 | FM_COST_TYPE: Mtto. Correctivo
=== SERIE ENTRENAMIENTO 2021-2023 === FECHA,valor 2021-01-01,349.3419384615381 2021-02-01,1314.0121972027978 2021-03-01,268.4100153846158 2021-04-01,402.1224461538466 2021-05-01,728.7152615384616 2021-06-01,0.0 2021-07-01,695.6266769230766 2021-08-01,53.7296615384615 2021-09-01,453.01681538461565 2021-10-01,25.09999999999999 2021-11-01,36.70769230769229 2021-12-01,865.7773076923067 2022-01-01,101.11558461538458 2022-02-01,7.26769230769231 2022-03-01,215.92163076923072 2022-04-01,56.91538461538464 2022-05-01,375.5271999999998 2022-06-01,692.0130769230775 2022-07-01,248.4935999999999 2022-08-01,142.0847076923076 2022-09-01,380.8114307692304 2022-10-01,2189.4840307692307 2022-11-01,207.2112153846158 2022-12-01,439.4492000000002 2023-01-01,204.6260307692308 2023-02-01,430.231461538462 2023-03-01,19.3737230769231 2023-04-01,175.47186153846138 2023-05-01,263.4378000000001 2023-06-01,262.2343846153846 2023-07-01,79.1907538461539 2023-08-01,251.1000000000001 2023-09-01,354.1569692307689 2023-10-01,0.0 2023-11-01,69.92215384615385 2023-12-01,562.8817846153843 === SERIE REAL 2024 === FECHA,valor 2024-01-01,481.277415384615 2024-02-01,137.23230769230773 2024-03-01,188.23076923076928 2024-04-01,30.710769230769213 2024-05-01,342.0891846153844 2024-06-01,158.61043076923073 2024-07-01,41.97695384615386 2024-08-01,48.961538461538396 2024-09-01,170.70307692307685 2024-10-01,30.423076923076902 2024-11-01,60.03152307692304 2024-12-01,0.0
Interpretación¶
Observamos un oulier, por lo que aplicamos la siguiente logica:
Dejamos un bloque único y reutilizable para aplicar a cualquier serie (y, si queremos, a todo un panel por parejas). Incluye:
Detección de outliers por IQR.
Tratamiento de outliers positivos (aplanado al límite superior + redistribución anual uniforme del exceso).
Tratamiento de outliers negativos con tus reglas:
Si la suma anual es negativa, eliminamos el outlier (lo llevamos a 0) sin redistribuir.
Si la suma anual es positiva, subimos outliers negativos a 0 y rebajamos proporcionalmente el resto de meses positivos del año sin dejar ningún mes por debajo de 0.
Variables generadas:
is_outlier (1/0),
cost_float_mod_2 (ajustada con reglas),
cost_float_mean_y (media anual original en años con outliers),
cost_float_mod_3 (original pero con meses outliers sustituidos por la media anual de su año).
Definimos las funciones que aplican esta transformación de outliers a series y a dataframes enteros de series.¶
# ============================================================
# Funciones auxiliares
# ============================================================
def _ensure_monthly(df, date_col="FECHA"):
"""
Aseguramos que la serie tenga frecuencia mensual continua entre FECHA min y max.
Rellenamos huecos con 0. No extrapolamos fuera del rango observado.
"""
df = df.copy()
df[date_col] = pd.to_datetime(df[date_col])
df = df.sort_values(date_col)
fmin, fmax = df[date_col].min(), df[date_col].max()
idx = pd.date_range(fmin, fmax, freq="MS")
df = df.set_index(date_col).reindex(idx)
df.index.name = date_col
df = df.reset_index()
return df
def _iqr_limits(x: pd.Series):
"""
Calculamos Q1, Q3, IQR y devolvemos límites inferior y superior para IQR.
"""
Q1 = x.quantile(0.25)
Q3 = x.quantile(0.75)
IQR = Q3 - Q1
lim_inf = Q1 - 1.5 * IQR
lim_sup = Q3 + 1.5 * IQR
return float(lim_inf), float(lim_sup)
def _redistribuir_deficit_sin_negativos(vals: np.ndarray, deficit: float) -> np.ndarray:
"""
Rebajamos 'vals' (>=0) para absorber 'deficit' (>=0) de forma proporcional sin dejar nada < 0.
Implementamos un bucle con topes (caps) para respetar el mínimo 0.
"""
s = vals.astype(float).copy()
rem = float(deficit)
if rem <= 0:
return s
# Iteramos hasta absorber el déficit o quedarnos sin masa positiva
while rem > 1e-12:
pos_mask = s > 0
total_pos = s[pos_mask].sum()
if total_pos <= 0:
# No hay masa positiva; salimos
break
# Proporción de reducción
reduccion = np.zeros_like(s)
reduccion[pos_mask] = rem * (s[pos_mask] / total_pos)
# Tope: nadie puede quedar por debajo de 0
cap = s.copy()
exceso_cap = np.maximum(reduccion - cap, 0.0)
reduccion_efectiva = reduccion - exceso_cap
# Aplicamos
s = s - reduccion_efectiva
s[s < 0] = 0.0
# Actualizamos remanente
rem = float(exceso_cap.sum())
if rem <= 1e-12:
rem = 0.0
break
return s
# ============================================================
# Transformación de UNA SERIE (FECHA + valor)
# ============================================================
def transformar_serie_outliers(
df_serie: pd.DataFrame,
date_col: str = "FECHA",
value_col: str = "cost_float_mod", # si nuestra serie trae 'valor', lo indicamos aquí
asegurar_mensual: bool = True
) -> pd.DataFrame:
"""
Dado un DataFrame con columnas [FECHA, value_col], generamos:
- is_outlier (IQR global de la serie)
- cost_float_mod_2 (ajustada con reglas para outliers +/-)
- cost_float_mean_y (media anual original si el año tiene outliers; NaN si no)
- cost_float_mod_3 (original con outliers sustituidos por cost_float_mean_y)
Devolvemos un DataFrame ordenado por FECHA con YEAR y las nuevas columnas.
"""
# Copiamos y normalizamos columnas
st = df_serie[[date_col, value_col]].copy()
st[date_col] = pd.to_datetime(st[date_col])
st = st.sort_values(date_col)
# Aseguramos mensualidad continua entre min y max
if asegurar_mensual:
st = _ensure_monthly(st, date_col=date_col)
# Rellenamos NaN del valor con 0 después de reindexar
if value_col not in st.columns:
# si reindexó sin la columna, la añadimos a 0
st[value_col] = 0.0
st[value_col] = st[value_col].fillna(0.0)
# Añadimos YEAR y alineamos tipo float
st["YEAR"] = st[date_col].dt.year
st[value_col] = st[value_col].astype(float)
# Calculamos límites IQR sobre la serie completa
lim_inf, lim_sup = _iqr_limits(st[value_col])
# Etiquetamos outliers
st["is_outlier"] = ((st[value_col] < lim_inf) | (st[value_col] > lim_sup)).astype(int)
# Inicializamos la serie ajustada con los valores originales
st["cost_float_mod"] = st[value_col] # alias explícito
st["cost_float_mod_2"] = st["cost_float_mod"].astype(float)
# 1) Tratamos OUTLIERS POSITIVOS: aplanamos al lim_sup y redistribuimos exceso anual
for y in sorted(st["YEAR"].unique()):
mask_y = st["YEAR"] == y
vals_y = st.loc[mask_y, "cost_float_mod_2"].values
# Exceso sobre el límite superior
exceso_vec = np.maximum(vals_y - lim_sup, 0.0)
exceso_total = float(exceso_vec.sum())
if exceso_total > 0:
# Aplanamos al límite superior
st.loc[mask_y, "cost_float_mod_2"] = np.minimum(vals_y, lim_sup)
# Redistribución uniforme del exceso anual
n_meses = int(mask_y.sum())
st.loc[mask_y, "cost_float_mod_2"] += exceso_total / n_meses
# 2) Tratamos OUTLIERS NEGATIVOS con las dos reglas
for y in sorted(st["YEAR"].unique()):
mask_y = st["YEAR"] == y
# Valores ORIGINALES para detectar outliers negativos
vals_y_orig = st.loc[mask_y, "cost_float_mod"].values
# Valores AJUSTADOS tras positivos
vals_y_adj = st.loc[mask_y, "cost_float_mod_2"].values
out_neg_mask = vals_y_orig < lim_inf
if not np.any(out_neg_mask):
continue
suma_anual = float(vals_y_orig.sum())
if suma_anual < 0:
# Regla 1: suma anual negativa -> eliminamos outlier (a 0) sin redistribuir
vals_y_adj[out_neg_mask] = 0.0
st.loc[mask_y, "cost_float_mod_2"] = vals_y_adj
else:
# Regla 2: suma anual positiva -> subimos outliers negativos a 0 y
# rebajamos meses positivos proporcionalmente sin dejar nada < 0
deficit = float(np.abs(vals_y_adj[out_neg_mask]).sum())
vals_y_adj[out_neg_mask] = 0.0
if deficit > 0:
vals_y_adj = _redistribuir_deficit_sin_negativos(vals_y_adj, deficit)
st.loc[mask_y, "cost_float_mod_2"] = vals_y_adj
# 3) cost_float_mean_y: media anual ORIGINAL en años con al menos un outlier
st["cost_float_mean_y"] = np.nan
years_con_outliers = (
st.groupby("YEAR")["is_outlier"].max().pipe(lambda s: s[s == 1].index.tolist())
)
if years_con_outliers:
media_anual = st.groupby("YEAR")["cost_float_mod"].mean()
for y in years_con_outliers:
st.loc[st["YEAR"] == y, "cost_float_mean_y"] = media_anual.loc[y]
# 4) cost_float_mod_3: original, sustituyendo outliers por la media anual de su año
st["cost_float_mod_3"] = st["cost_float_mod"]
m_out = st["is_outlier"] == 1
st.loc[m_out, "cost_float_mod_3"] = st.loc[m_out, "cost_float_mean_y"]
# Salida ordenada
out = st[[date_col, "YEAR", "cost_float_mod", "is_outlier",
"cost_float_mod_2", "cost_float_mean_y", "cost_float_mod_3"]].copy()
out = out.sort_values(date_col).reset_index(drop=True)
# Información útil de control (opcional)
print(f"Límites IQR -> lim_inf: {lim_inf:.4f} | lim_sup: {lim_sup:.4f}")
return out
# ============================================================
# Transformación de TODO UN PANEL (por parejas)
# ============================================================
def transformar_panel_outliers(
df_panel: pd.DataFrame,
id_cols=("ID_BUILDING","FM_COST_TYPE"),
date_col="FECHA",
value_col="cost_float_mod",
asegurar_mensual=True
) -> pd.DataFrame:
"""
Aplicamos la transformación a todo el panel agrupando por id_cols.
Devolvemos un DataFrame con las columnas originales mínimas (ids y FECHA)
y las nuevas variables generadas por serie.
"""
# Nos quedamos con columnas mínimas
cols_min = list(id_cols) + [date_col, value_col]
missing = set(cols_min) - set(df_panel.columns)
if missing:
raise ValueError(f"Faltan columnas en df_panel: {missing}")
df_panel = df_panel[cols_min].copy()
df_panel[date_col] = pd.to_datetime(df_panel[date_col])
# Aplicamos por grupo
out_list = []
for keys, g in df_panel.groupby(list(id_cols), sort=False):
g = g.sort_values(date_col)
# Transformamos la serie del grupo
t = transformar_serie_outliers(
g[[date_col, value_col]],
date_col=date_col,
value_col=value_col,
asegurar_mensual=asegurar_mensual
)
# Reinyectamos las claves del grupo
for i, col in enumerate(id_cols):
t[col] = keys[i] if isinstance(keys, tuple) else keys
# Ordenamos columnas
out_list.append(t)
out = pd.concat(out_list, ignore_index=True)
out = out[list(id_cols) + [date_col, "YEAR",
"cost_float_mod","is_outlier",
"cost_float_mod_2","cost_float_mean_y","cost_float_mod_3"]]
out = out.sort_values(list(id_cols) + [date_col]).reset_index(drop=True)
return out
# ============================================================
# Ejemplos de uso
# ============================================================
# 1) Para UNA serie (por ejemplo, s_train que ya tenemos con columnas FECHA y 'valor'):
# Si nuestra columna se llama 'valor', pasamos value_col='valor'
# serie_enriquecida = transformar_serie_outliers(s_train.rename(columns={"valor":"cost_float_mod"}),
# date_col="FECHA", value_col="cost_float_mod")
# 2) Para TODO el panel agregado mensual (p. ej., df_fd1_v5_ITE1_train):
# panel_enriquecido = transformar_panel_outliers(df_fd1_v5_ITE1_train,
# id_cols=("ID_BUILDING","FM_COST_TYPE"),
# date_col="FECHA",
# value_col="cost_float_mod",
# asegurar_mensual=True)
Aplicamos práctica de la función a la Pareja seleccionada -> ID_BUILDING: 1000480 | FM_COST_TYPE: Mtto. Correctivo¶
# ============================================================
# 2) Aplicamos a la serie TRAIN pegada
# ============================================================
# En nuestro caso, la columna se llama 'valor' -> la mapeamos a 'cost_float_mod'
serie_train_enriquecida = transformar_serie_outliers(
s_train.rename(columns={"valor": "cost_float_mod"}),
date_col="FECHA",
value_col="cost_float_mod",
asegurar_mensual=True
)
print("Resumen serie enriquecida (primeros 12 meses):")
print(serie_train_enriquecida.head(12))
# ============================================================
# 3) Diagnóstico de cambio estructural vs 2024
# ============================================================
# Calculamos la media de 2024 (real)
media_2024 = s_real["valor"].mean()
# Función auxiliar para evaluar y mostrar diagnóstico
def diagnostico_cambio(nombre, serie_train, media_test, umbral=0.30):
media_train = serie_train.mean()
ratio = abs(media_test - media_train) / (media_train if media_train != 0 else 1.0)
print(f"\n== Diagnóstico {nombre} ==")
print(f"Media 2021–2023: {media_train:.2f}")
print(f"Media 2024 : {media_test:.2f}")
print(f"Cambio relativo: {ratio:.2%}")
if ratio > umbral:
print("Conclusión: detectamos cambio estructural (caída/subida de nivel relevante).")
else:
print("Conclusión: no parece haber cambio estructural relevante en medias.")
# Aplicamos a las tres variantes del conjunto train enriquecido
diagnostico_cambio("Original (cost_float_mod)",
serie_train_enriquecida["cost_float_mod"], media_2024)
diagnostico_cambio("Ajustada (cost_float_mod_2)",
serie_train_enriquecida["cost_float_mod_2"], media_2024)
diagnostico_cambio("Outliers sustituidos (cost_float_mod_3)",
serie_train_enriquecida["cost_float_mod_3"], media_2024)
Límites IQR -> lim_inf: -456.6198 | lim_sup: 966.0293 Resumen serie enriquecida (primeros 12 meses): FECHA YEAR cost_float_mod is_outlier cost_float_mod_2 \ 0 2021-01-01 2021 349.341938 0 378.340510 1 2021-02-01 2021 1314.012197 1 995.027906 2 2021-03-01 2021 268.410015 0 297.408587 3 2021-04-01 2021 402.122446 0 431.121018 4 2021-05-01 2021 728.715262 0 757.713833 5 2021-06-01 2021 0.000000 0 28.998572 6 2021-07-01 2021 695.626677 0 724.625249 7 2021-08-01 2021 53.729662 0 82.728233 8 2021-09-01 2021 453.016815 0 482.015387 9 2021-10-01 2021 25.100000 0 54.098572 10 2021-11-01 2021 36.707692 0 65.706264 11 2021-12-01 2021 865.777308 0 894.775880 cost_float_mean_y cost_float_mod_3 0 432.713334 349.341938 1 432.713334 432.713334 2 432.713334 268.410015 3 432.713334 402.122446 4 432.713334 728.715262 5 432.713334 0.000000 6 432.713334 695.626677 7 432.713334 53.729662 8 432.713334 453.016815 9 432.713334 25.100000 10 432.713334 36.707692 11 432.713334 865.777308 == Diagnóstico Original (cost_float_mod) == Media 2021–2023: 358.93 Media 2024 : 140.85 Cambio relativo: 60.76% Conclusión: detectamos cambio estructural (caída/subida de nivel relevante). == Diagnóstico Ajustada (cost_float_mod_2) == Media 2021–2023: 358.93 Media 2024 : 140.85 Cambio relativo: 60.76% Conclusión: detectamos cambio estructural (caída/subida de nivel relevante). == Diagnóstico Outliers sustituidos (cost_float_mod_3) == Media 2021–2023: 285.33 Media 2024 : 140.85 Cambio relativo: 50.64% Conclusión: detectamos cambio estructural (caída/subida de nivel relevante).
Visualizamos los cambios graficamente entre los distintos costes (mod, mod2 y mod3) y sus previsiones.¶
# Graficamos la serie original y las variantes junto con los valores reales de 2024
plt.figure(figsize=(12,6))
# Original
plt.plot(serie_train_enriquecida["FECHA"],
serie_train_enriquecida["cost_float_mod"],
label="Train original (cost_float_mod)", color="blue")
# Ajustada
plt.plot(serie_train_enriquecida["FECHA"],
serie_train_enriquecida["cost_float_mod_2"],
label="Train ajustada (cost_float_mod_2)", color="green")
# Outliers sustituidos
plt.plot(serie_train_enriquecida["FECHA"],
serie_train_enriquecida["cost_float_mod_3"],
label="Train outliers→media (cost_float_mod_3)", color="orange")
# Serie real 2024
plt.plot(s_real["FECHA"],
s_real["valor"],
label="Real 2024", color="red", linestyle="--")
plt.title("Comparativa serie train (2021–2023) vs real 2024\nID_BUILDING 1000480 | FM_COST_TYPE Mtto. Correctivo")
plt.xlabel("Fecha")
plt.ylabel("Coste")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Interpretación¶
Con la serie que ya transformamos (cost_float_mod, cost_float_mod_2, cost_float_mod_3) podemos montar un experimento de predicción 2021–2023 → 2024 y comparar cuál de las tres versiones se ajusta mejor al real 2024.
Estrategia que vamos a seguir
Entrenamos tres modelos Holt-Winters simples (SES), uno con cada variable (cost_float_mod, cost_float_mod_2, cost_float_mod_3).
Es suficiente SES porque la serie no tiene tendencia ni estacionalidad marcada, y así la comparación se centra en la calidad de la transformación de costes.
Predecimos los 12 meses de 2024.
Calculamos métricas para cada variante:
MAE
WAPE
SMAPE
MASE (con naïve simple como referencia).
Comparamos resultados y vemos cuál de las tres series (original, ajustada o corregida con medias) es más modelizable respecto al real 2024.
Podemos complementar las métricas con un análisis de precisión anual: comparar la suma de todos los meses previstos en 2024 frente a la suma real de 2024. Esto nos dice si el modelo sobrestima o infraestima en el total anual, más allá del error mensual.
Mejora de la metrica de validación evaluando el acumulado anual de las previsiones vs. realidad.¶
# --- 1) Preparamos la serie real 2024 ---
serie_real = s_real.set_index("FECHA")["valor"]
# --- 2) Definimos una función auxiliar para entrenar y evaluar SES ---
def evaluar_ses(serie_train, serie_real, nombre=""):
# Ajustamos SES
modelo = SimpleExpSmoothing(serie_train).fit(optimized=True)
pred = modelo.forecast(len(serie_real))
pred.index = serie_real.index
# Métricas
mae = mean_absolute_error(serie_real, pred)
wape = np.sum(np.abs(serie_real - pred)) / np.sum(np.abs(serie_real))
smape = (100/len(serie_real)) * np.sum(
2 * np.abs(pred - serie_real) / (np.abs(serie_real) + np.abs(pred))
)
# MASE con naïve
naive1 = serie_train.shift(1).dropna()
mae_naive1 = np.mean(np.abs(serie_train[1:] - naive1))
mase = mae / mae_naive1
# Precisión anual
suma_pred = pred.sum()
suma_real = serie_real.sum()
error_abs = abs(suma_pred - suma_real)
error_rel = error_abs / abs(suma_real) if suma_real != 0 else np.nan
print(f"\n=== RESULTADOS SES ({nombre}) ===")
print(f"MAE : {mae:.2f}")
print(f"WAPE : {wape:.2%}")
print(f"SMAPE : {smape:.2f}%")
print(f"MASE : {mase:.3f}")
print(f"Suma predicha: {suma_pred:.2f} | Suma real: {suma_real:.2f}")
print(f"Error anual absoluto: {error_abs:.2f}")
print(f"Error anual relativo: {error_rel:.2%}")
return pred
# --- 3) Evaluamos con cada variable de coste ---
serie_train_mod = serie_train_enriquecida.set_index("FECHA")["cost_float_mod"]
serie_train_mod2 = serie_train_enriquecida.set_index("FECHA")["cost_float_mod_2"]
serie_train_mod3 = serie_train_enriquecida.set_index("FECHA")["cost_float_mod_3"]
pred_mod = evaluar_ses(serie_train_mod, serie_real, nombre="Original (mod)")
pred_mod2 = evaluar_ses(serie_train_mod2, serie_real, nombre="Ajustada (mod_2)")
pred_mod3 = evaluar_ses(serie_train_mod3, serie_real, nombre="Outliers→Media (mod_3)")
# --- 4) Visualizamos comparaciones ---
import matplotlib.pyplot as plt
plt.figure(figsize=(10,5))
plt.plot(serie_train_mod.index, serie_train_mod.values, label="Entrenamiento (mod)", color="blue")
plt.plot(serie_real.index, serie_real.values, label="Real 2024", color="black")
plt.plot(pred_mod.index, pred_mod.values, label="Pred mod", color="red", linestyle="--")
plt.plot(pred_mod2.index, pred_mod2.values, label="Pred mod_2", color="green", linestyle="--")
plt.plot(pred_mod3.index, pred_mod3.values, label="Pred mod_3", color="orange", linestyle="--")
plt.title("Comparación predicciones SES con 3 transformaciones de costes")
plt.xlabel("Fecha"); plt.ylabel("Coste mensual (€)")
plt.legend()
plt.tight_layout()
plt.show()
/usr/local/lib/python3.12/dist-packages/statsmodels/tsa/base/tsa_model.py:473: ValueWarning: No frequency information was provided, so inferred frequency MS will be used. self._init_dates(dates, freq) /usr/local/lib/python3.12/dist-packages/statsmodels/tsa/base/tsa_model.py:473: ValueWarning: No frequency information was provided, so inferred frequency MS will be used. self._init_dates(dates, freq) /usr/local/lib/python3.12/dist-packages/statsmodels/tsa/base/tsa_model.py:473: ValueWarning: No frequency information was provided, so inferred frequency MS will be used. self._init_dates(dates, freq)
=== RESULTADOS SES (Original (mod)) === MAE : 230.48 WAPE : 163.63% SMAPE : 109.14% MASE : 0.525 Suma predicha: 4192.10 | Suma real: 1690.25 Error anual absoluto: 2501.86 Error anual relativo: 148.02% === RESULTADOS SES (Ajustada (mod_2)) === MAE : 254.64 WAPE : 180.78% SMAPE : 112.65% MASE : 0.727 Suma predicha: 4540.09 | Suma real: 1690.25 Error anual absoluto: 2849.84 Error anual relativo: 168.60% === RESULTADOS SES (Outliers→Media (mod_3)) === MAE : 188.59 WAPE : 133.89% SMAPE : 102.92% MASE : 0.655 Suma predicha: 3459.83 | Suma real: 1690.25 Error anual absoluto: 1769.58 Error anual relativo: 104.69%
Ahora vamos a aplicar esta transformación para tratar los outliers a todo el conjunto train.
Enriquecemos el conjunto de train con las nuevas variables de coste ajustadas sin outliers.¶
# Creamos versión enriquecida del TRAIN si no existen columnas
if not {"cost_float_mod_2","cost_float_mod_3"}.issubset(df_fd1_v5_ITE1_train.columns):
print("Generamos columnas mod_2 y mod_3 en TRAIN...")
df_train_enr = transformar_panel_outliers(df_fd1_v5_ITE1_train,
id_cols=("ID_BUILDING","FM_COST_TYPE"),
date_col="FECHA",
value_col="cost_float_mod",
asegurar_mensual=True)
else:
df_train_enr = df_fd1_v5_ITE1_train.copy()
Generamos columnas mod_2 y mod_3 en TRAIN... Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -41.9625 | lim_sup: 69.9375 Límites IQR -> lim_inf: -379.8725 | lim_sup: 2816.6275 Límites IQR -> lim_inf: -876.8238 | lim_sup: 1632.3063 Límites IQR -> lim_inf: -378.7650 | lim_sup: 631.2750 Límites IQR -> lim_inf: 5657.1087 | lim_sup: 6909.1587 Límites IQR -> lim_inf: -220.5225 | lim_sup: 367.5375 Límites IQR -> lim_inf: -2230.7325 | lim_sup: 23705.3475 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -356.4937 | lim_sup: 594.1562 Límites IQR -> lim_inf: -594.8237 | lim_sup: 3707.6663 Límites IQR -> lim_inf: -928.1637 | lim_sup: 3528.2463 Límites IQR -> lim_inf: -627.0000 | lim_sup: 1045.0000 Límites IQR -> lim_inf: -3514.8425 | lim_sup: 48546.1775 Límites IQR -> lim_inf: -287.1112 | lim_sup: 656.3587 Límites IQR -> lim_inf: -1677.3825 | lim_sup: 18622.1775 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -67.5000 | lim_sup: 112.5000 Límites IQR -> lim_inf: -754.1700 | lim_sup: 5000.4700 Límites IQR -> lim_inf: -930.0275 | lim_sup: 1741.0925 Límites IQR -> lim_inf: -7137.7688 | lim_sup: 12088.4213 Límites IQR -> lim_inf: -1511.4300 | lim_sup: 14135.4100 Límites IQR -> lim_inf: -448.7275 | lim_sup: 848.7125 Límites IQR -> lim_inf: 52.8275 | lim_sup: 10907.8075 Límites IQR -> lim_inf: 218.7800 | lim_sup: 218.7800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -393.7500 | lim_sup: 656.2500 Límites IQR -> lim_inf: -61.2000 | lim_sup: 102.0000 Límites IQR -> lim_inf: -301.2600 | lim_sup: 502.1000 Límites IQR -> lim_inf: -140.0000 | lim_sup: 380.0000 Límites IQR -> lim_inf: -1803.1650 | lim_sup: 3005.2750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -380.8425 | lim_sup: 634.7375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1232.8050 | lim_sup: 2054.6750 Límites IQR -> lim_inf: -515.7525 | lim_sup: 859.5875 Límites IQR -> lim_inf: -3355.9925 | lim_sup: 6157.4875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 267.9700 | lim_sup: 267.9700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -910.2000 | lim_sup: 1517.0000 Límites IQR -> lim_inf: 4350.0000 | lim_sup: 4350.0000 Límites IQR -> lim_inf: 683.3637 | lim_sup: 1135.7538 Límites IQR -> lim_inf: -1346.1637 | lim_sup: 2243.6062 Límites IQR -> lim_inf: -686.0088 | lim_sup: 3411.4813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -302.5250 | lim_sup: 865.9150 Límites IQR -> lim_inf: -634.7625 | lim_sup: 1535.4575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2915.2050 | lim_sup: 4882.6850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1380.3787 | lim_sup: 7470.6512 Límites IQR -> lim_inf: -140.6250 | lim_sup: 234.3750 Límites IQR -> lim_inf: 165.3000 | lim_sup: 224.1000 Límites IQR -> lim_inf: -1068.5925 | lim_sup: 2577.4275 Límites IQR -> lim_inf: 2897.3450 | lim_sup: 7979.3050 Límites IQR -> lim_inf: -259.5000 | lim_sup: 432.5000 Límites IQR -> lim_inf: -2755.9150 | lim_sup: 12519.8850 Límites IQR -> lim_inf: -322.5000 | lim_sup: 537.5000 Límites IQR -> lim_inf: -39.7450 | lim_sup: 367.5750 Límites IQR -> lim_inf: -688.9863 | lim_sup: 1608.4038 Límites IQR -> lim_inf: 294.7500 | lim_sup: 294.7500 Límites IQR -> lim_inf: 1466.3050 | lim_sup: 3399.2250 Límites IQR -> lim_inf: -325.5000 | lim_sup: 542.5000 Límites IQR -> lim_inf: -1355.3275 | lim_sup: 7331.5125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -298.7350 | lim_sup: 799.2250 Límites IQR -> lim_inf: -1230.4925 | lim_sup: 2269.7075 Límites IQR -> lim_inf: 914.8950 | lim_sup: 2662.8550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -939.2462 | lim_sup: 5479.2237 Límites IQR -> lim_inf: -285.0000 | lim_sup: 475.0000 Límites IQR -> lim_inf: -701.6488 | lim_sup: 1538.1613 Límites IQR -> lim_inf: -853.4075 | lim_sup: 2636.0725 Límites IQR -> lim_inf: 2622.3300 | lim_sup: 4943.0500 Límites IQR -> lim_inf: -483.4800 | lim_sup: 805.8000 Límites IQR -> lim_inf: -1770.5225 | lim_sup: 11209.4375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 48.6250 | lim_sup: 454.0250 Límites IQR -> lim_inf: -692.5350 | lim_sup: 1742.0250 Límites IQR -> lim_inf: 2604.4050 | lim_sup: 4919.0850 Límites IQR -> lim_inf: -703.8300 | lim_sup: 1173.0500 Límites IQR -> lim_inf: -1008.6700 | lim_sup: 9450.7300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -400.0875 | lim_sup: 745.3525 Límites IQR -> lim_inf: -784.2725 | lim_sup: 1877.4275 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 1457.1650 | lim_sup: 2714.6450 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -499.2138 | lim_sup: 6577.9563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.4763 | lim_sup: 296.3862 Límites IQR -> lim_inf: -703.5688 | lim_sup: 1302.3613 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1485.7375 | lim_sup: 2767.2775 Límites IQR -> lim_inf: 360.0000 | lim_sup: 360.0000 Límites IQR -> lim_inf: -682.7875 | lim_sup: 3893.2525 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -216.2075 | lim_sup: 601.0125 Límites IQR -> lim_inf: -1064.6600 | lim_sup: 2703.9600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2839.7450 | lim_sup: 5509.4650 Límites IQR -> lim_inf: -98.6362 | lim_sup: 164.3937 Límites IQR -> lim_inf: -4045.5300 | lim_sup: 11732.5900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -506.8950 | lim_sup: 938.8650 Límites IQR -> lim_inf: -543.7063 | lim_sup: 1149.6037 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 1460.5188 | lim_sup: 2700.4087 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -230.6900 | lim_sup: 4303.6500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 502.6350 | lim_sup: 774.2750 Límites IQR -> lim_inf: -1447.8250 | lim_sup: 2500.1750 Límites IQR -> lim_inf: -139.6050 | lim_sup: 232.6750 Límites IQR -> lim_inf: 101.0000 | lim_sup: 101.0000 Límites IQR -> lim_inf: -827.2375 | lim_sup: 9166.1625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 257.7450 | lim_sup: 290.1050 Límites IQR -> lim_inf: -1035.0150 | lim_sup: 2589.5850 Límites IQR -> lim_inf: 165.0000 | lim_sup: 165.0000 Límites IQR -> lim_inf: -1056.4650 | lim_sup: 1760.7750 Límites IQR -> lim_inf: -2657.9825 | lim_sup: 10456.9375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -183.5625 | lim_sup: 305.9375 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -396.8862 | lim_sup: 2326.6437 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1560.0000 | lim_sup: 3800.0000 Límites IQR -> lim_inf: -708.0100 | lim_sup: 1575.2300 Límites IQR -> lim_inf: -97.9200 | lim_sup: 163.2000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -3436.4763 | lim_sup: 12803.4938 Límites IQR -> lim_inf: 254.6100 | lim_sup: 254.6100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -156.6250 | lim_sup: 1486.0550 Límites IQR -> lim_inf: -922.7700 | lim_sup: 3113.5900 Límites IQR -> lim_inf: -2483.8275 | lim_sup: 4139.7125 Límites IQR -> lim_inf: 4072.4275 | lim_sup: 5568.6875 Límites IQR -> lim_inf: -201.3750 | lim_sup: 335.6250 Límites IQR -> lim_inf: -2274.8512 | lim_sup: 14995.8587 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -276.2250 | lim_sup: 1366.4550 Límites IQR -> lim_inf: -1078.9388 | lim_sup: 2852.0312 Límites IQR -> lim_inf: 2272.9337 | lim_sup: 4303.0838 Límites IQR -> lim_inf: -175.4062 | lim_sup: 292.3438 Límites IQR -> lim_inf: -3455.7387 | lim_sup: 15252.5712 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -731.9462 | lim_sup: 2017.7238 Límites IQR -> lim_inf: -1183.9475 | lim_sup: 2862.1525 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3127.7600 | lim_sup: 4067.8400 Límites IQR -> lim_inf: -116.4375 | lim_sup: 194.0625 Límites IQR -> lim_inf: -3657.9050 | lim_sup: 15227.6350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 190.9100 | lim_sup: 190.9100 Límites IQR -> lim_inf: -269.6063 | lim_sup: 734.9838 Límites IQR -> lim_inf: 467.6650 | lim_sup: 5800.8250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -4000.5525 | lim_sup: 11137.1075 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1086.6075 | lim_sup: 7493.8125 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: 384.0000 | lim_sup: 384.0000 Límites IQR -> lim_inf: -557.3100 | lim_sup: 977.2700 Límites IQR -> lim_inf: 798.3600 | lim_sup: 2377.8000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -690.2550 | lim_sup: 9532.3450 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 384.0000 | lim_sup: 384.0000 Límites IQR -> lim_inf: -145.1100 | lim_sup: 241.8500 Límites IQR -> lim_inf: 947.4800 | lim_sup: 2119.5600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1903.1737 | lim_sup: 9158.9562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 120.0000 | lim_sup: 120.0000 Límites IQR -> lim_inf: -982.7175 | lim_sup: 2256.2225 Límites IQR -> lim_inf: 654.2525 | lim_sup: 3516.1925 Límites IQR -> lim_inf: -414.7425 | lim_sup: 691.2375 Límites IQR -> lim_inf: -1143.3300 | lim_sup: 8137.9500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 120.0000 | lim_sup: 120.0000 Límites IQR -> lim_inf: -411.6937 | lim_sup: 686.1562 Límites IQR -> lim_inf: 709.9488 | lim_sup: 1640.2187 Límites IQR -> lim_inf: -63.6000 | lim_sup: 106.0000 Límites IQR -> lim_inf: -646.8938 | lim_sup: 4234.3963 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -459.2288 | lim_sup: 1786.9012 Límites IQR -> lim_inf: -708.0437 | lim_sup: 1646.5262 Límites IQR -> lim_inf: 1345.2800 | lim_sup: 1345.2800 Límites IQR -> lim_inf: 1230.0600 | lim_sup: 4694.3800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1201.6600 | lim_sup: 9330.9000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -289.6675 | lim_sup: 1761.7125 Límites IQR -> lim_inf: -808.2862 | lim_sup: 2520.2837 Límites IQR -> lim_inf: 3566.3400 | lim_sup: 5867.7000 Límites IQR -> lim_inf: -55.5000 | lim_sup: 92.5000 Límites IQR -> lim_inf: -1928.6787 | lim_sup: 11553.4312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 565.5800 | lim_sup: 565.5800 Límites IQR -> lim_inf: -471.3825 | lim_sup: 980.1375 Límites IQR -> lim_inf: 1595.2137 | lim_sup: 3395.4637 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1462.4262 | lim_sup: 7508.9037 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -338.1225 | lim_sup: 563.5375 Límites IQR -> lim_inf: 293.7750 | lim_sup: 330.6950 Límites IQR -> lim_inf: -34.9500 | lim_sup: 316.9700 Límites IQR -> lim_inf: -227.8025 | lim_sup: 1070.7375 Límites IQR -> lim_inf: 180.0000 | lim_sup: 180.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -211.4250 | lim_sup: 352.3750 Límites IQR -> lim_inf: 276.4200 | lim_sup: 311.1400 Límites IQR -> lim_inf: -166.8000 | lim_sup: 278.0000 Límites IQR -> lim_inf: -226.7063 | lim_sup: 993.3838 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 326.0700 | lim_sup: 326.0700 Límites IQR -> lim_inf: -626.7750 | lim_sup: 1148.6250 Límites IQR -> lim_inf: -1785.0888 | lim_sup: 11668.3013 Límites IQR -> lim_inf: 2800.0400 | lim_sup: 3916.0400 Límites IQR -> lim_inf: -236.6250 | lim_sup: 394.3750 Límites IQR -> lim_inf: -231.9625 | lim_sup: 3304.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -677.2463 | lim_sup: 1128.7438 Límites IQR -> lim_inf: 323.7850 | lim_sup: 364.4650 Límites IQR -> lim_inf: -502.0575 | lim_sup: 836.7625 Límites IQR -> lim_inf: -96.8550 | lim_sup: 904.1050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -696.3787 | lim_sup: 1352.2313 Límites IQR -> lim_inf: 450.0000 | lim_sup: 450.0000 Límites IQR -> lim_inf: 661.2250 | lim_sup: 883.0250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -600.9975 | lim_sup: 2440.6825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 282.9950 | lim_sup: 378.2350 Límites IQR -> lim_inf: -420.0000 | lim_sup: 700.0000 Límites IQR -> lim_inf: -322.1038 | lim_sup: 1501.6263 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -88.2375 | lim_sup: 147.0625 Límites IQR -> lim_inf: 53.9250 | lim_sup: 60.6850 Límites IQR -> lim_inf: 20.6025 | lim_sup: 185.4225 Límites IQR -> lim_inf: -55.0475 | lim_sup: 412.3725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -159.0000 | lim_sup: 265.0000 Límites IQR -> lim_inf: -600.7500 | lim_sup: 1001.2500 Límites IQR -> lim_inf: 431.0700 | lim_sup: 485.2300 Límites IQR -> lim_inf: -615.2400 | lim_sup: 1457.4000 Límites IQR -> lim_inf: -119.6812 | lim_sup: 1053.2887 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -387.0000 | lim_sup: 645.0000 Límites IQR -> lim_inf: 282.6200 | lim_sup: 377.4200 Límites IQR -> lim_inf: -330.0000 | lim_sup: 550.0000 Límites IQR -> lim_inf: -389.9350 | lim_sup: 1539.8650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -461.8838 | lim_sup: 769.8063 Límites IQR -> lim_inf: -1520.6925 | lim_sup: 3177.6875 Límites IQR -> lim_inf: 291.6850 | lim_sup: 389.5650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -213.2588 | lim_sup: 1376.7313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -333.3525 | lim_sup: 555.5875 Límites IQR -> lim_inf: 678.0000 | lim_sup: 678.0000 Límites IQR -> lim_inf: 307.6100 | lim_sup: 410.8100 Límites IQR -> lim_inf: -420.0000 | lim_sup: 700.0000 Límites IQR -> lim_inf: -63.0100 | lim_sup: 1189.0100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1369.3788 | lim_sup: 2493.5513 Límites IQR -> lim_inf: 813.1550 | lim_sup: 1693.7550 Límites IQR -> lim_inf: -202.5000 | lim_sup: 337.5000 Límites IQR -> lim_inf: -334.2188 | lim_sup: 1492.8913 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -376.9350 | lim_sup: 628.2250 Límites IQR -> lim_inf: -2833.3700 | lim_sup: 8500.1100 Límites IQR -> lim_inf: 164.6900 | lim_sup: 219.9700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -123.9825 | lim_sup: 786.9575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -226.8000 | lim_sup: 378.0000 Límites IQR -> lim_inf: 320.1650 | lim_sup: 427.5650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -304.3800 | lim_sup: 987.1600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -156.6000 | lim_sup: 261.0000 Límites IQR -> lim_inf: -2065.5500 | lim_sup: 10120.4500 Límites IQR -> lim_inf: 268.4050 | lim_sup: 302.1250 Límites IQR -> lim_inf: -165.0000 | lim_sup: 355.0000 Límites IQR -> lim_inf: -372.8038 | lim_sup: 745.1862 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 89.6200 | lim_sup: 119.7000 Límites IQR -> lim_inf: -6.2862 | lim_sup: 85.9837 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.9000 | lim_sup: 176.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -162.9500 | lim_sup: 1635.8700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -317.4000 | lim_sup: 529.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -194.2687 | lim_sup: 2093.8212 Límites IQR -> lim_inf: -270.0000 | lim_sup: 450.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -263.8500 | lim_sup: 439.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -905.5350 | lim_sup: 2747.3850 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -253.8375 | lim_sup: 423.0625 Límites IQR -> lim_inf: 650.0000 | lim_sup: 650.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -802.7500 | lim_sup: 1381.2500 Límites IQR -> lim_inf: -123.4525 | lim_sup: 2331.3675 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -113.1187 | lim_sup: 188.5312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -351.6412 | lim_sup: 1699.8487 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -207.7500 | lim_sup: 346.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -204.7925 | lim_sup: 504.6275 Límites IQR -> lim_inf: -718.6987 | lim_sup: 2729.5112 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -284.4937 | lim_sup: 521.8362 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -46.4600 | lim_sup: 168.1000 Límites IQR -> lim_inf: -254.6175 | lim_sup: 2052.6225 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -311.1000 | lim_sup: 518.5000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -501.9112 | lim_sup: 2819.1988 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -398.0888 | lim_sup: 663.4813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 65.0000 | lim_sup: 65.0000 Límites IQR -> lim_inf: -393.2337 | lim_sup: 2369.3763 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -309.0000 | lim_sup: 515.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 65.0000 | lim_sup: 65.0000 Límites IQR -> lim_inf: -1167.2550 | lim_sup: 3330.9250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -522.3750 | lim_sup: 870.6250 Límites IQR -> lim_inf: 43.7500 | lim_sup: 43.7500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -513.8238 | lim_sup: 2589.5463 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -621.2025 | lim_sup: 1284.5975 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -405.8225 | lim_sup: 3253.6175 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -218.9625 | lim_sup: 364.9375 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -257.2662 | lim_sup: 1885.3837 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -277.4287 | lim_sup: 462.3812 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -265.7537 | lim_sup: 2623.0563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -445.9425 | lim_sup: 743.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -10.3475 | lim_sup: 1368.5725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -402.0000 | lim_sup: 670.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -504.0213 | lim_sup: 1884.6688 Límites IQR -> lim_inf: -41.5688 | lim_sup: 69.2812 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -154.3875 | lim_sup: 1532.1325 Límites IQR -> lim_inf: -41.5688 | lim_sup: 69.2812 Límites IQR -> lim_inf: -115.3800 | lim_sup: 192.3000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -110.6637 | lim_sup: 1935.4262 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -407.7075 | lim_sup: 679.5125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -219.6225 | lim_sup: 2161.5575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -134.7375 | lim_sup: 224.5625 Límites IQR -> lim_inf: -448.8850 | lim_sup: 830.2750 Límites IQR -> lim_inf: -316.7700 | lim_sup: 527.9500 Límites IQR -> lim_inf: 165.0000 | lim_sup: 165.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1455.5550 | lim_sup: 4580.1050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.1200 | lim_sup: 200.2000 Límites IQR -> lim_inf: -277.7550 | lim_sup: 462.9250 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -421.3262 | lim_sup: 2452.9637 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -277.1400 | lim_sup: 461.9000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -58.7500 | lim_sup: 271.2500 Límites IQR -> lim_inf: -305.1225 | lim_sup: 2079.1175 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -422.3100 | lim_sup: 703.8500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 15.8300 | lim_sup: 1913.5300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -317.5500 | lim_sup: 529.2500 Límites IQR -> lim_inf: -96.0000 | lim_sup: 160.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -54.9125 | lim_sup: 1795.3075 Límites IQR -> lim_inf: -481.3125 | lim_sup: 802.1875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -394.2900 | lim_sup: 657.1500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -543.7500 | lim_sup: 2567.4300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -57.7125 | lim_sup: 96.1875 Límites IQR -> lim_inf: -403.6500 | lim_sup: 672.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -786.4062 | lim_sup: 2792.2238 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -57.7125 | lim_sup: 96.1875 Límites IQR -> lim_inf: -71.2463 | lim_sup: 118.7438 Límites IQR -> lim_inf: 704.4100 | lim_sup: 704.4100 Límites IQR -> lim_inf: 55.0000 | lim_sup: 55.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -597.8275 | lim_sup: 2081.1725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -424.5000 | lim_sup: 835.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -304.2500 | lim_sup: 593.7500 Límites IQR -> lim_inf: -654.7275 | lim_sup: 2517.9525 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -360.0750 | lim_sup: 682.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -97.5000 | lim_sup: 162.5000 Límites IQR -> lim_inf: -522.6288 | lim_sup: 2367.1213 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -418.0500 | lim_sup: 696.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -160.9200 | lim_sup: 268.2000 Límites IQR -> lim_inf: -328.1275 | lim_sup: 2081.9925 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -378.9075 | lim_sup: 631.5125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -503.5825 | lim_sup: 2976.8375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -261.6000 | lim_sup: 436.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -407.2225 | lim_sup: 1996.2775 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -336.0000 | lim_sup: 560.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -371.0150 | lim_sup: 1867.0650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -575.5800 | lim_sup: 959.3000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -390.6587 | lim_sup: 3360.1312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -41.5688 | lim_sup: 69.2812 Límites IQR -> lim_inf: -103.0050 | lim_sup: 171.6750 Límites IQR -> lim_inf: -375.0000 | lim_sup: 625.0000 Límites IQR -> lim_inf: 98.9300 | lim_sup: 132.2100 Límites IQR -> lim_inf: 35.0000 | lim_sup: 35.0000 Límites IQR -> lim_inf: -74.7625 | lim_sup: 2500.5575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -323.8350 | lim_sup: 539.7250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -406.7187 | lim_sup: 2227.2512 Límites IQR -> lim_inf: 1112.0000 | lim_sup: 1112.0000 Límites IQR -> lim_inf: -200.2200 | lim_sup: 333.7000 Límites IQR -> lim_inf: -186.0975 | lim_sup: 310.1625 Límites IQR -> lim_inf: -1282.3675 | lim_sup: 2910.6125 Límites IQR -> lim_inf: 361.1850 | lim_sup: 482.3450 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -21.3800 | lim_sup: 403.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -489.0000 | lim_sup: 815.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -165.2963 | lim_sup: 1854.5538 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 225.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: -663.4000 | lim_sup: 1415.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -398.7600 | lim_sup: 664.6000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -106.8938 | lim_sup: 1707.1363 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -326.0362 | lim_sup: 574.6337 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -231.6675 | lim_sup: 2179.6725 Límites IQR -> lim_inf: -90.7500 | lim_sup: 151.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -401.8875 | lim_sup: 669.8125 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -490.0850 | lim_sup: 2468.5150 Límites IQR -> lim_inf: -90.7500 | lim_sup: 151.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -253.3625 | lim_sup: 504.9375 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -804.8450 | lim_sup: 3200.7350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -470.2988 | lim_sup: 783.8313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -139.2663 | lim_sup: 1974.6638 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -290.5088 | lim_sup: 484.1813 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -217.3800 | lim_sup: 1819.3000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -171.7500 | lim_sup: 286.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -57.9763 | lim_sup: 1875.6337 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -438.8550 | lim_sup: 731.4250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 715.0000 | lim_sup: 715.0000 Límites IQR -> lim_inf: -384.5400 | lim_sup: 2583.2000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -147.2250 | lim_sup: 245.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 116.1200 | lim_sup: 116.1200 Límites IQR -> lim_inf: -170.2088 | lim_sup: 1872.6413 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -244.9650 | lim_sup: 408.2750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -185.3225 | lim_sup: 2029.8775 Límites IQR -> lim_inf: -217.5000 | lim_sup: 362.5000 Límites IQR -> lim_inf: -1745.6400 | lim_sup: 2909.4000 Límites IQR -> lim_inf: -648.5825 | lim_sup: 1682.1975 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1985.5800 | lim_sup: 4568.7800 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: -1950.3713 | lim_sup: 8340.7388 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -150.1200 | lim_sup: 1139.0800 Límites IQR -> lim_inf: -484.8188 | lim_sup: 1827.6713 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4402.8200 | lim_sup: 7047.5400 Límites IQR -> lim_inf: -217.8750 | lim_sup: 363.1250 Límites IQR -> lim_inf: -4084.8625 | lim_sup: 21461.5575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -189.1200 | lim_sup: 315.2000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -594.4575 | lim_sup: 2971.7425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -328.8750 | lim_sup: 548.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 165.0000 | lim_sup: 165.0000 Límites IQR -> lim_inf: -493.0750 | lim_sup: 2582.6650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -251.0625 | lim_sup: 418.4375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -309.3663 | lim_sup: 2965.3238 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 90.0000 | lim_sup: 90.0000 Límites IQR -> lim_inf: -30.7413 | lim_sup: 266.3088 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -78.2000 | lim_sup: 363.4000 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -162.6675 | lim_sup: 271.1125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -0.3975 | lim_sup: 210.3425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -504.2700 | lim_sup: 840.4500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -229.1025 | lim_sup: 2663.2575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -308.0138 | lim_sup: 513.3563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 79.9600 | lim_sup: 79.9600 Límites IQR -> lim_inf: -281.6938 | lim_sup: 2438.6562 Límites IQR -> lim_inf: 259.1000 | lim_sup: 259.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -398.7000 | lim_sup: 664.5000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -283.3388 | lim_sup: 1736.5312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -41.5688 | lim_sup: 69.2812 Límites IQR -> lim_inf: -556.9462 | lim_sup: 928.2437 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -433.7588 | lim_sup: 2504.6912 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -3044.0887 | lim_sup: 7257.6812 Límites IQR -> lim_inf: -2746.3637 | lim_sup: 10400.0863 Límites IQR -> lim_inf: 15986.7687 | lim_sup: 20945.9988 Límites IQR -> lim_inf: -2686.6788 | lim_sup: 5146.9113 Límites IQR -> lim_inf: -11694.2663 | lim_sup: 66165.1237 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 108.0987 | lim_sup: 113.1687 Límites IQR -> lim_inf: -92.3238 | lim_sup: 2135.4262 Límites IQR -> lim_inf: -337.5000 | lim_sup: 562.5000 Límites IQR -> lim_inf: 64.0000 | lim_sup: 64.0000 Límites IQR -> lim_inf: -32.9962 | lim_sup: 54.9937 Límites IQR -> lim_inf: 408.7300 | lim_sup: 408.7300 Límites IQR -> lim_inf: -218.3550 | lim_sup: 1027.4250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -438.9375 | lim_sup: 731.5625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -408.0212 | lim_sup: 2328.5687 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -168.2550 | lim_sup: 280.4250 Límites IQR -> lim_inf: -765.9750 | lim_sup: 2981.5050 Límites IQR -> lim_inf: -2160.8600 | lim_sup: 3973.5400 Límites IQR -> lim_inf: -120.5100 | lim_sup: 200.8500 Límites IQR -> lim_inf: 3274.5938 | lim_sup: 4526.4237 Límites IQR -> lim_inf: -1174.4175 | lim_sup: 2515.7225 Límites IQR -> lim_inf: -8174.7375 | lim_sup: 25244.0825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -459.1500 | lim_sup: 765.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -141.4512 | lim_sup: 844.9187 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 122.2650 | lim_sup: 163.2650 Límites IQR -> lim_inf: -39.3025 | lim_sup: 208.9575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 180.7850 | lim_sup: 241.4650 Límites IQR -> lim_inf: -36.4087 | lim_sup: 326.0612 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 46.9800 | lim_sup: 46.9800 Límites IQR -> lim_inf: -159.0987 | lim_sup: 338.7312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 113.3500 | lim_sup: 127.5900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -284.4425 | lim_sup: 762.1975 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -242.1112 | lim_sup: 403.5187 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -294.5100 | lim_sup: 490.8500 Límites IQR -> lim_inf: -141.9762 | lim_sup: 2034.2137 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -41.5688 | lim_sup: 69.2812 Límites IQR -> lim_inf: -308.4750 | lim_sup: 514.1250 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -265.6250 | lim_sup: 2869.3550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -270.0000 | lim_sup: 450.0000 Límites IQR -> lim_inf: -586.7700 | lim_sup: 3611.7300 Límites IQR -> lim_inf: -2543.1400 | lim_sup: 8837.3200 Límites IQR -> lim_inf: -2654.4275 | lim_sup: 6108.5525 Límites IQR -> lim_inf: 8309.1950 | lim_sup: 12900.8750 Límites IQR -> lim_inf: -1251.5250 | lim_sup: 2085.8750 Límites IQR -> lim_inf: -3925.5437 | lim_sup: 52528.3262 Límites IQR -> lim_inf: 213.9045 | lim_sup: 590.0864 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -830.0489 | lim_sup: 3770.4693 Límites IQR -> lim_inf: 3764.1182 | lim_sup: 5488.4091 Límites IQR -> lim_inf: -153.4091 | lim_sup: 255.6818 Límites IQR -> lim_inf: 11944.0000 | lim_sup: 15333.2727 Límites IQR -> lim_inf: 97.2500 | lim_sup: 523.3955 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1300.9727 | lim_sup: 3527.3909 Límites IQR -> lim_inf: 4439.9682 | lim_sup: 6987.2045 Límites IQR -> lim_inf: -547.1591 | lim_sup: 911.9318 Límites IQR -> lim_inf: 10153.0705 | lim_sup: 12210.8886 Límites IQR -> lim_inf: 73.9182 | lim_sup: 510.0636 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -1900.2636 | lim_sup: 4142.8636 Límites IQR -> lim_inf: 3890.3591 | lim_sup: 7365.0864 Límites IQR -> lim_inf: -545.5091 | lim_sup: 909.1818 Límites IQR -> lim_inf: 8762.0307 | lim_sup: 10998.3852 Límites IQR -> lim_inf: 48.8500 | lim_sup: 595.6500 Límites IQR -> lim_inf: -504.5455 | lim_sup: 1022.7273 Límites IQR -> lim_inf: -2705.6250 | lim_sup: 8006.0295 Límites IQR -> lim_inf: 6490.1591 | lim_sup: 8466.3409 Límites IQR -> lim_inf: -1153.9000 | lim_sup: 1965.5909 Límites IQR -> lim_inf: 15128.7080 | lim_sup: 19879.3352 Límites IQR -> lim_inf: -34.4364 | lim_sup: 266.3636 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -798.7295 | lim_sup: 1904.6341 Límites IQR -> lim_inf: 3494.4545 | lim_sup: 3730.8182 Límites IQR -> lim_inf: -252.2727 | lim_sup: 420.4545 Límites IQR -> lim_inf: 1417.3125 | lim_sup: 9850.0852 Límites IQR -> lim_inf: 165.9182 | lim_sup: 360.7545 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -1142.4648 | lim_sup: 2595.0170 Límites IQR -> lim_inf: 3570.0455 | lim_sup: 3851.8636 Límites IQR -> lim_inf: -86.9318 | lim_sup: 144.8864 Límites IQR -> lim_inf: 2413.8864 | lim_sup: 12284.2500 Límites IQR -> lim_inf: 136.1318 | lim_sup: 636.3136 Límites IQR -> lim_inf: 88.6364 | lim_sup: 143.1818 Límites IQR -> lim_inf: -1357.7761 | lim_sup: 4135.5239 Límites IQR -> lim_inf: 4212.2955 | lim_sup: 6126.5864 Límites IQR -> lim_inf: -340.9091 | lim_sup: 568.1818 Límites IQR -> lim_inf: 8293.5261 | lim_sup: 12404.0807 Límites IQR -> lim_inf: 128.3227 | lim_sup: 308.4318 Límites IQR -> lim_inf: 88.6364 | lim_sup: 143.1818 Límites IQR -> lim_inf: -1121.2955 | lim_sup: 3449.3591 Límites IQR -> lim_inf: 4709.3068 | lim_sup: 5475.9795 Límites IQR -> lim_inf: -352.6091 | lim_sup: 587.6818 Límites IQR -> lim_inf: 4979.1568 | lim_sup: 12827.1205 Límites IQR -> lim_inf: 103.2682 | lim_sup: 594.3591 Límites IQR -> lim_inf: 1142.2727 | lim_sup: 1193.1818 Límites IQR -> lim_inf: -2395.6045 | lim_sup: 7782.8318 Límites IQR -> lim_inf: 4677.7636 | lim_sup: 6861.5455 Límites IQR -> lim_inf: -490.9091 | lim_sup: 818.1818 Límites IQR -> lim_inf: 11931.5955 | lim_sup: 25971.0136 Límites IQR -> lim_inf: 95.3273 | lim_sup: 735.4727 Límites IQR -> lim_inf: -136.3636 | lim_sup: 409.0909 Límites IQR -> lim_inf: -1020.3398 | lim_sup: 3532.9420 Límites IQR -> lim_inf: 6706.9091 | lim_sup: 7841.4545 Límites IQR -> lim_inf: -409.2375 | lim_sup: 682.0625 Límites IQR -> lim_inf: 15369.4330 | lim_sup: 18380.4966 Límites IQR -> lim_inf: 51.4000 | lim_sup: 479.1818 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2160.1034 | lim_sup: 5507.9602 Límites IQR -> lim_inf: 5386.5909 | lim_sup: 8283.6818 Límites IQR -> lim_inf: -81.8182 | lim_sup: 136.3636 Límites IQR -> lim_inf: 8010.1000 | lim_sup: 15238.2818 Límites IQR -> lim_inf: 103.2682 | lim_sup: 594.3591 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -842.7182 | lim_sup: 3866.0091 Límites IQR -> lim_inf: 5439.6591 | lim_sup: 8479.8409 Límites IQR -> lim_inf: -230.1136 | lim_sup: 383.5227 Límites IQR -> lim_inf: 15417.5034 | lim_sup: 18399.2216 Límites IQR -> lim_inf: 105.0045 | lim_sup: 495.4409 Límites IQR -> lim_inf: -250.0000 | lim_sup: 659.0909 Límites IQR -> lim_inf: -809.3216 | lim_sup: 2374.7239 Límites IQR -> lim_inf: 3879.0523 | lim_sup: 7361.5795 Límites IQR -> lim_inf: -761.7648 | lim_sup: 1269.6080 Límites IQR -> lim_inf: 7503.6693 | lim_sup: 10433.5148 Límites IQR -> lim_inf: 93.1955 | lim_sup: 678.3955 Límites IQR -> lim_inf: -284.0909 | lim_sup: 715.9091 Límites IQR -> lim_inf: -1896.1977 | lim_sup: 4910.0386 Límites IQR -> lim_inf: 5278.6045 | lim_sup: 7358.1318 Límites IQR -> lim_inf: -322.3841 | lim_sup: 537.3068 Límites IQR -> lim_inf: 11828.9159 | lim_sup: 14922.3159 Límites IQR -> lim_inf: 257.7955 | lim_sup: 552.3045 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2222.7455 | lim_sup: 5458.1273 Límites IQR -> lim_inf: 5535.6864 | lim_sup: 8363.1409 Límites IQR -> lim_inf: -1055.5682 | lim_sup: 1801.7045 Límites IQR -> lim_inf: 12337.7330 | lim_sup: 15916.2693 Límites IQR -> lim_inf: 121.8455 | lim_sup: 206.4273 Límites IQR -> lim_inf: 31.8182 | lim_sup: 68.1818 Límites IQR -> lim_inf: -840.0852 | lim_sup: 1771.4148 Límites IQR -> lim_inf: 3586.6364 | lim_sup: 3729.1818 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2360.0750 | lim_sup: 13028.1114 Límites IQR -> lim_inf: 493.5364 | lim_sup: 493.5364 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1293.0739 | lim_sup: 5463.0625 Límites IQR -> lim_inf: 63.6364 | lim_sup: 63.6364 Límites IQR -> lim_inf: 7163.3636 | lim_sup: 7327.7273 Límites IQR -> lim_inf: -498.4091 | lim_sup: 830.6818 Límites IQR -> lim_inf: 16545.4534 | lim_sup: 21226.8261 Límites IQR -> lim_inf: 183.9591 | lim_sup: 675.3409 Límites IQR -> lim_inf: 155.0273 | lim_sup: 155.0273 Límites IQR -> lim_inf: -995.8795 | lim_sup: 2348.8659 Límites IQR -> lim_inf: 3550.1364 | lim_sup: 3624.6818 Límites IQR -> lim_inf: -51.1364 | lim_sup: 85.2273 Límites IQR -> lim_inf: 6334.9148 | lim_sup: 8596.7239 Límites IQR -> lim_inf: 59.0455 | lim_sup: 587.0455 Límites IQR -> lim_inf: 224.7545 | lim_sup: 417.4091 Límites IQR -> lim_inf: -1937.6614 | lim_sup: 5341.7932 Límites IQR -> lim_inf: 5641.0364 | lim_sup: 6585.0364 Límites IQR -> lim_inf: -323.8636 | lim_sup: 539.7727 Límites IQR -> lim_inf: 19353.7273 | lim_sup: 25012.9455 Límites IQR -> lim_inf: 95.7636 | lim_sup: 510.8182 Límites IQR -> lim_inf: -3179.7545 | lim_sup: 5299.5909 Límites IQR -> lim_inf: -2224.3182 | lim_sup: 10137.2636 Límites IQR -> lim_inf: 5464.0727 | lim_sup: 8442.1091 Límites IQR -> lim_inf: -771.8045 | lim_sup: 1286.3409 Límites IQR -> lim_inf: 14454.5398 | lim_sup: 18920.6398 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -303.6300 | lim_sup: 506.0500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -77.3200 | lim_sup: 1286.1200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -306.0000 | lim_sup: 510.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -388.7600 | lim_sup: 1513.6800 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -332.2275 | lim_sup: 553.7125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -477.2600 | lim_sup: 2411.6400 Límites IQR -> lim_inf: -219.3750 | lim_sup: 365.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -374.7000 | lim_sup: 624.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -621.9137 | lim_sup: 2793.1762 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1735.5000 | lim_sup: 2892.5000 Límites IQR -> lim_inf: -11.5312 | lim_sup: 263.5387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -201.3750 | lim_sup: 335.6250 Límites IQR -> lim_inf: 55.5350 | lim_sup: 200.7750 Límites IQR -> lim_inf: 254.0000 | lim_sup: 254.0000 Límites IQR -> lim_inf: -171.8425 | lim_sup: 2639.1375 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -204.8625 | lim_sup: 341.4375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -253.7463 | lim_sup: 2791.2638 Límites IQR -> lim_inf: 170.5591 | lim_sup: 665.9409 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1953.5830 | lim_sup: 4507.0989 Límites IQR -> lim_inf: 4839.2909 | lim_sup: 5052.7455 Límites IQR -> lim_inf: -204.5455 | lim_sup: 340.9091 Límites IQR -> lim_inf: 12031.5943 | lim_sup: 14234.2216 Límites IQR -> lim_inf: 154.3591 | lim_sup: 159.7045 Límites IQR -> lim_inf: 454.5455 | lim_sup: 454.5455 Límites IQR -> lim_inf: -1013.1409 | lim_sup: 1688.5682 Límites IQR -> lim_inf: 614.3818 | lim_sup: 1653.8727 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1113.0852 | lim_sup: 6315.4943 Límites IQR -> lim_inf: 101.2864 | lim_sup: 520.8136 Límites IQR -> lim_inf: 500.0000 | lim_sup: 500.0000 Límites IQR -> lim_inf: -1497.9432 | lim_sup: 3980.6386 Límites IQR -> lim_inf: 4222.0682 | lim_sup: 7528.0682 Límites IQR -> lim_inf: -409.0841 | lim_sup: 681.8068 Límites IQR -> lim_inf: 23276.9955 | lim_sup: 30792.1591 Límites IQR -> lim_inf: -623.6260 | lim_sup: 1545.2744 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -152.2667 | lim_sup: 290.2419 Límites IQR -> lim_inf: 1701.4328 | lim_sup: 6724.7450 Límites IQR -> lim_inf: 109.4848 | lim_sup: 3461.5042 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -549.4245 | lim_sup: 1021.3075 Límites IQR -> lim_inf: 1899.1721 | lim_sup: 13528.7486 Límites IQR -> lim_inf: -500.8709 | lim_sup: 1175.7180 Límites IQR -> lim_inf: -97.8944 | lim_sup: 215.8870 Límites IQR -> lim_inf: 1289.4087 | lim_sup: 1830.9220 Límites IQR -> lim_inf: -516.2367 | lim_sup: 1678.9204 Límites IQR -> lim_inf: -121.5787 | lim_sup: 239.0312 Límites IQR -> lim_inf: 850.5422 | lim_sup: 3638.5524 Límites IQR -> lim_inf: -474.5459 | lim_sup: 1028.4315 Límites IQR -> lim_inf: -22.3700 | lim_sup: 77.9500 Límites IQR -> lim_inf: 754.2430 | lim_sup: 2688.8861 Límites IQR -> lim_inf: -411.3802 | lim_sup: 970.8361 Límites IQR -> lim_inf: -66.2400 | lim_sup: 110.4000 Límites IQR -> lim_inf: 988.1715 | lim_sup: 3304.7007 Límites IQR -> lim_inf: -607.1558 | lim_sup: 1335.7595 Límites IQR -> lim_inf: -38.0100 | lim_sup: 95.8300 Límites IQR -> lim_inf: 284.1209 | lim_sup: 4268.9192 Límites IQR -> lim_inf: 68.8800 | lim_sup: 109.2000 Límites IQR -> lim_inf: -1299.1851 | lim_sup: 3619.9331 Límites IQR -> lim_inf: -246.9456 | lim_sup: 455.0827 Límites IQR -> lim_inf: 1253.2759 | lim_sup: 6658.6674 Límites IQR -> lim_inf: -816.6443 | lim_sup: 1859.8154 Límites IQR -> lim_inf: 4441.6963 | lim_sup: 5309.9263 Límites IQR -> lim_inf: -148.6665 | lim_sup: 412.1055 Límites IQR -> lim_inf: 406.5040 | lim_sup: 586.5283 Límites IQR -> lim_inf: -485.4447 | lim_sup: 948.2313 Límites IQR -> lim_inf: -70.5659 | lim_sup: 117.6099 Límites IQR -> lim_inf: 976.6573 | lim_sup: 1087.5349 Límites IQR -> lim_inf: -1164.9207 | lim_sup: 5764.3863 Límites IQR -> lim_inf: -803.4491 | lim_sup: 1409.4819 Límites IQR -> lim_inf: 2809.9389 | lim_sup: 13371.0307 Límites IQR -> lim_inf: -245.2192 | lim_sup: 482.6183 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -28.7400 | lim_sup: 77.4680 Límites IQR -> lim_inf: -5.3611 | lim_sup: 1056.8327 Límites IQR -> lim_inf: -407.3199 | lim_sup: 814.6060 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -19.6015 | lim_sup: 46.7961 Límites IQR -> lim_inf: 212.7136 | lim_sup: 1144.8378 Límites IQR -> lim_inf: -1396.5583 | lim_sup: 6345.7265 Límites IQR -> lim_inf: -504.4228 | lim_sup: 1126.9580 Límites IQR -> lim_inf: 5604.3070 | lim_sup: 26104.8960 Límites IQR -> lim_inf: -728.2979 | lim_sup: 4505.6372 Límites IQR -> lim_inf: -467.8102 | lim_sup: 990.2170 Límites IQR -> lim_inf: 3378.1440 | lim_sup: 17894.7560 Límites IQR -> lim_inf: -476.0184 | lim_sup: 1119.2622 Límites IQR -> lim_inf: -95.7560 | lim_sup: 202.2600 Límites IQR -> lim_inf: 471.7773 | lim_sup: 3940.4348 Límites IQR -> lim_inf: -779.4589 | lim_sup: 1949.3563 Límites IQR -> lim_inf: -120.1125 | lim_sup: 264.4275 Límites IQR -> lim_inf: 757.8717 | lim_sup: 5858.3394 Límites IQR -> lim_inf: -1000.7213 | lim_sup: 3068.7344 Límites IQR -> lim_inf: -139.2126 | lim_sup: 301.6210 Límites IQR -> lim_inf: 1013.3390 | lim_sup: 7557.5202 Límites IQR -> lim_inf: -726.2362 | lim_sup: 1557.2446 Límites IQR -> lim_inf: -57.9150 | lim_sup: 128.5250 Límites IQR -> lim_inf: 251.3045 | lim_sup: 2576.9444 Límites IQR -> lim_inf: -424.4872 | lim_sup: 827.9092 Límites IQR -> lim_inf: -22.9280 | lim_sup: 56.6112 Límites IQR -> lim_inf: 282.2997 | lim_sup: 1197.3144 Límites IQR -> lim_inf: -277.1423 | lim_sup: 920.3685 Límites IQR -> lim_inf: -63.9528 | lim_sup: 138.5880 Límites IQR -> lim_inf: -5.0062 | lim_sup: 3854.5030 Límites IQR -> lim_inf: -286.3220 | lim_sup: 837.9436 Límites IQR -> lim_inf: -69.0159 | lim_sup: 139.0265 Límites IQR -> lim_inf: 390.2628 | lim_sup: 3921.7104 Límites IQR -> lim_inf: -771.5521 | lim_sup: 1577.7935 Límites IQR -> lim_inf: -49.7587 | lim_sup: 121.4592 Límites IQR -> lim_inf: 830.6945 | lim_sup: 2232.3452 Límites IQR -> lim_inf: -323.2201 | lim_sup: 749.3978 Límites IQR -> lim_inf: -62.6400 | lim_sup: 126.5760 Límites IQR -> lim_inf: 117.3858 | lim_sup: 1464.7287 Límites IQR -> lim_inf: -390.6372 | lim_sup: 902.5856 Límites IQR -> lim_inf: -110.3500 | lim_sup: 224.0500 Límites IQR -> lim_inf: 37.3177 | lim_sup: 2789.6522 Límites IQR -> lim_inf: -180.8982 | lim_sup: 553.8232 Límites IQR -> lim_inf: -63.0037 | lim_sup: 156.6062 Límites IQR -> lim_inf: 489.3038 | lim_sup: 3347.7999 Límites IQR -> lim_inf: -212.9365 | lim_sup: 498.2173 Límites IQR -> lim_inf: -78.1125 | lim_sup: 178.1875 Límites IQR -> lim_inf: 179.5700 | lim_sup: 2064.4936 Límites IQR -> lim_inf: -518.5243 | lim_sup: 2379.5109 Límites IQR -> lim_inf: -24.4659 | lim_sup: 104.7765 Límites IQR -> lim_inf: 1325.1181 | lim_sup: 10134.3692 Límites IQR -> lim_inf: -318.1363 | lim_sup: 751.9305 Límites IQR -> lim_inf: -36.8400 | lim_sup: 93.4000 Límites IQR -> lim_inf: 623.9692 | lim_sup: 3273.3901 Límites IQR -> lim_inf: -843.2727 | lim_sup: 1951.0892 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -50.1750 | lim_sup: 115.6250 Límites IQR -> lim_inf: -841.6530 | lim_sup: 4948.6968 Límites IQR -> lim_inf: -24.5967 | lim_sup: 1563.0198 Límites IQR -> lim_inf: -82.4895 | lim_sup: 185.4825 Límites IQR -> lim_inf: 1187.2826 | lim_sup: 6902.0096 Límites IQR -> lim_inf: -648.4745 | lim_sup: 1460.7215 Límites IQR -> lim_inf: -21.8613 | lim_sup: 84.9688 Límites IQR -> lim_inf: 1445.1106 | lim_sup: 6278.4522 Límites IQR -> lim_inf: -436.4840 | lim_sup: 1001.5000 Límites IQR -> lim_inf: -38.0225 | lim_sup: 112.7575 Límites IQR -> lim_inf: -681.0997 | lim_sup: 8205.9061 Límites IQR -> lim_inf: -168.1700 | lim_sup: 1050.9500 Límites IQR -> lim_inf: -225.4025 | lim_sup: 438.3647 Límites IQR -> lim_inf: 306.7065 | lim_sup: 4020.6481 Límites IQR -> lim_inf: -621.9629 | lim_sup: 1766.7953 Límites IQR -> lim_inf: -117.5500 | lim_sup: 265.2500 Límites IQR -> lim_inf: 806.7714 | lim_sup: 5335.3213 Límites IQR -> lim_inf: -349.3534 | lim_sup: 985.9064 Límites IQR -> lim_inf: -99.8500 | lim_sup: 187.7500 Límites IQR -> lim_inf: 860.3250 | lim_sup: 2533.0286 Límites IQR -> lim_inf: -726.1325 | lim_sup: 2031.8875 Límites IQR -> lim_inf: -66.9825 | lim_sup: 225.7895 Límites IQR -> lim_inf: 500.2857 | lim_sup: 3392.2669 Límites IQR -> lim_inf: -343.7205 | lim_sup: 840.7019 Límites IQR -> lim_inf: -61.0722 | lim_sup: 101.7870 Límites IQR -> lim_inf: 330.0806 | lim_sup: 1942.4234 Límites IQR -> lim_inf: -443.6007 | lim_sup: 1093.2595 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -131.5500 | lim_sup: 219.2500 Límites IQR -> lim_inf: 576.6447 | lim_sup: 2360.0537 Límites IQR -> lim_inf: -506.7464 | lim_sup: 2228.4747 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -186.1087 | lim_sup: 358.1812 Límites IQR -> lim_inf: 2571.1106 | lim_sup: 6995.0231 Límites IQR -> lim_inf: -467.1099 | lim_sup: 1254.6571 Límites IQR -> lim_inf: -39.3670 | lim_sup: 86.9450 Límites IQR -> lim_inf: 874.9282 | lim_sup: 3130.9005 Límites IQR -> lim_inf: -329.7701 | lim_sup: 696.9361 Límites IQR -> lim_inf: -75.6000 | lim_sup: 126.0000 Límites IQR -> lim_inf: 238.3319 | lim_sup: 1001.9058 Límites IQR -> lim_inf: -1039.4335 | lim_sup: 2089.5939 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -215.5565 | lim_sup: 417.9275 Límites IQR -> lim_inf: 888.0866 | lim_sup: 4367.7675 Límites IQR -> lim_inf: -413.1050 | lim_sup: 1307.0684 Límites IQR -> lim_inf: -80.9810 | lim_sup: 179.7683 Límites IQR -> lim_inf: 1506.0915 | lim_sup: 2995.9759 Límites IQR -> lim_inf: -895.9638 | lim_sup: 3381.3652 Límites IQR -> lim_inf: -496.8982 | lim_sup: 1190.4964 Límites IQR -> lim_inf: 688.6497 | lim_sup: 10713.9367 Límites IQR -> lim_inf: -391.4056 | lim_sup: 1129.2597 Límites IQR -> lim_inf: -74.4600 | lim_sup: 156.1000 Límites IQR -> lim_inf: 542.7695 | lim_sup: 2470.9024 Límites IQR -> lim_inf: -441.5856 | lim_sup: 1297.9680 Límites IQR -> lim_inf: -85.5000 | lim_sup: 174.5000 Límites IQR -> lim_inf: 482.2110 | lim_sup: 4172.5382 Límites IQR -> lim_inf: -589.2091 | lim_sup: 2333.1211 Límites IQR -> lim_inf: -28.5175 | lim_sup: 96.8625 Límites IQR -> lim_inf: 791.1437 | lim_sup: 5771.3149 Límites IQR -> lim_inf: -362.0118 | lim_sup: 974.4926 Límites IQR -> lim_inf: 3570.8524 | lim_sup: 3570.8524 Límites IQR -> lim_inf: -94.0470 | lim_sup: 211.6250 Límites IQR -> lim_inf: 1080.7015 | lim_sup: 4500.4045 Límites IQR -> lim_inf: -525.0076 | lim_sup: 1557.6906 Límites IQR -> lim_inf: -72.5500 | lim_sup: 158.2500 Límites IQR -> lim_inf: 489.0133 | lim_sup: 4057.7613 Límites IQR -> lim_inf: -452.3453 | lim_sup: 963.1967 Límites IQR -> lim_inf: -32.0925 | lim_sup: 85.4875 Límites IQR -> lim_inf: 254.8974 | lim_sup: 2431.0275 Límites IQR -> lim_inf: -451.3292 | lim_sup: 1125.5487 Límites IQR -> lim_inf: -138.8022 | lim_sup: 231.3370 Límites IQR -> lim_inf: -70.4224 | lim_sup: 3136.9844 Límites IQR -> lim_inf: -414.0118 | lim_sup: 986.2142 Límites IQR -> lim_inf: -60.6750 | lim_sup: 140.7250 Límites IQR -> lim_inf: 30.2744 | lim_sup: 2189.7177 Límites IQR -> lim_inf: -1657.4863 | lim_sup: 6542.8351 Límites IQR -> lim_inf: 3651.8672 | lim_sup: 3651.8672 Límites IQR -> lim_inf: -260.3801 | lim_sup: 1689.9668 Límites IQR -> lim_inf: -3966.2396 | lim_sup: 12756.0354 Límites IQR -> lim_inf: -6.9000 | lim_sup: 11.5000 Límites IQR -> lim_inf: -1264.2180 | lim_sup: 4518.2186 Límites IQR -> lim_inf: -300.4339 | lim_sup: 579.8699 Límites IQR -> lim_inf: 2016.9301 | lim_sup: 7919.3361 Límites IQR -> lim_inf: -633.5679 | lim_sup: 4596.7742 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -399.0367 | lim_sup: 880.3091 Límites IQR -> lim_inf: -2164.9744 | lim_sup: 15651.7986 Límites IQR -> lim_inf: -90.0674 | lim_sup: 3059.1790 Límites IQR -> lim_inf: -482.8068 | lim_sup: 881.6513 Límites IQR -> lim_inf: 2377.8845 | lim_sup: 11525.4022 Límites IQR -> lim_inf: -566.2650 | lim_sup: 943.7750 Límites IQR -> lim_inf: -58.6800 | lim_sup: 97.8000 Límites IQR -> lim_inf: 77.5526 | lim_sup: 445.2898 Límites IQR -> lim_inf: -652.8481 | lim_sup: 1400.3639 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -108.6527 | lim_sup: 234.4211 Límites IQR -> lim_inf: 605.9152 | lim_sup: 4473.7203 Límites IQR -> lim_inf: -857.3446 | lim_sup: 4676.7670 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -363.9162 | lim_sup: 786.5270 Límites IQR -> lim_inf: 867.6791 | lim_sup: 11532.2800 Límites IQR -> lim_inf: -848.7227 | lim_sup: 5065.4940 Límites IQR -> lim_inf: -458.5846 | lim_sup: 833.6410 Límites IQR -> lim_inf: 1726.3470 | lim_sup: 11770.7725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -535.1025 | lim_sup: 891.8375 Límites IQR -> lim_inf: 145.6300 | lim_sup: 163.9500 Límites IQR -> lim_inf: -141.2250 | lim_sup: 235.3750 Límites IQR -> lim_inf: -255.7425 | lim_sup: 1266.4375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -277.9538 | lim_sup: 463.2563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 263.2000 | lim_sup: 263.2000 Límites IQR -> lim_inf: -102.9500 | lim_sup: 1693.2900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 122.4100 | lim_sup: 122.4100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 753.6300 | lim_sup: 1536.8300 Límites IQR -> lim_inf: -2505.0950 | lim_sup: 9183.6050 Límites IQR -> lim_inf: 924.0000 | lim_sup: 924.0000 Límites IQR -> lim_inf: -34.6150 | lim_sup: 351.0250 Límites IQR -> lim_inf: -183.7500 | lim_sup: 306.2500 Límites IQR -> lim_inf: -5653.6362 | lim_sup: 39793.1137 Límites IQR -> lim_inf: 580.8900 | lim_sup: 580.8900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -130.1250 | lim_sup: 216.8750 Límites IQR -> lim_inf: 182.6500 | lim_sup: 214.4100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -84.7362 | lim_sup: 426.3338 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2552.2125 | lim_sup: 4683.3275 Límites IQR -> lim_inf: -1805.6138 | lim_sup: 4576.7163 Límites IQR -> lim_inf: 3958.7600 | lim_sup: 5286.7600 Límites IQR -> lim_inf: -714.4725 | lim_sup: 1190.7875 Límites IQR -> lim_inf: -3050.8938 | lim_sup: 24105.5163 Límites IQR -> lim_inf: 190.5636 | lim_sup: 576.7455 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -982.5852 | lim_sup: 2795.3693 Límites IQR -> lim_inf: 4716.2864 | lim_sup: 6954.5773 Límites IQR -> lim_inf: -136.3636 | lim_sup: 227.2727 Límites IQR -> lim_inf: 6756.1420 | lim_sup: 10778.6057 Límites IQR -> lim_inf: -263.2500 | lim_sup: 438.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -166.5337 | lim_sup: 277.5562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 217.1100 | lim_sup: 217.1100 Límites IQR -> lim_inf: -105.0000 | lim_sup: 175.0000 Límites IQR -> lim_inf: -391.4700 | lim_sup: 652.4500 Límites IQR -> lim_inf: -192.0000 | lim_sup: 320.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: -180.0000 | lim_sup: 300.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -413.7900 | lim_sup: 689.6500 Límites IQR -> lim_inf: -194.7000 | lim_sup: 324.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -393.0263 | lim_sup: 655.0438 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -308.3038 | lim_sup: 1407.8863 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -141.5400 | lim_sup: 235.9000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -225.3850 | lim_sup: 1409.0350 Límites IQR -> lim_inf: -135.0000 | lim_sup: 405.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -323.4675 | lim_sup: 539.1125 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -249.8713 | lim_sup: 1418.6588 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: -248.7750 | lim_sup: 414.6250 Límites IQR -> lim_inf: -214.9663 | lim_sup: 763.3637 Límites IQR -> lim_inf: -1313.5095 | lim_sup: 4700.3105 Límites IQR -> lim_inf: -479.6917 | lim_sup: 932.8195 Límites IQR -> lim_inf: 3316.1329 | lim_sup: 20122.5447 Límites IQR -> lim_inf: -97.6313 | lim_sup: 162.7188 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -402.9000 | lim_sup: 671.5000 Límites IQR -> lim_inf: -906.2200 | lim_sup: 4603.7000 Límites IQR -> lim_inf: -152.0362 | lim_sup: 568.8937 Límites IQR -> lim_inf: -8.5925 | lim_sup: 225.7275 Límites IQR -> lim_inf: -616.3688 | lim_sup: 1922.7812 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -475.3650 | lim_sup: 792.2750 Límites IQR -> lim_inf: 100.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: 100.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -308.8950 | lim_sup: 1928.4050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1310.2500 | lim_sup: 2183.7500 Límites IQR -> lim_inf: -924.1500 | lim_sup: 2220.5700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3285.1500 | lim_sup: 5700.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -833.5887 | lim_sup: 9669.3812 Límites IQR -> lim_inf: -34.8825 | lim_sup: 58.1375 Límites IQR -> lim_inf: 27.2312 | lim_sup: 748.6413 Límites IQR -> lim_inf: -898.8250 | lim_sup: 2412.1950 Límites IQR -> lim_inf: 2806.0300 | lim_sup: 10753.8700 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -2869.2900 | lim_sup: 14231.3300 Límites IQR -> lim_inf: 406.1800 | lim_sup: 406.1800 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -70.4213 | lim_sup: 493.2488 Límites IQR -> lim_inf: -189.0325 | lim_sup: 671.9875 Límites IQR -> lim_inf: -412.5000 | lim_sup: 687.5000 Límites IQR -> lim_inf: -852.3025 | lim_sup: 1732.7775 Límites IQR -> lim_inf: -1057.1637 | lim_sup: 2929.5063 Límites IQR -> lim_inf: -641.6237 | lim_sup: 2934.3663 Límites IQR -> lim_inf: -22.5000 | lim_sup: 37.5000 Límites IQR -> lim_inf: -115.3350 | lim_sup: 192.2250 Límites IQR -> lim_inf: -194.9287 | lim_sup: 1826.4212 Límites IQR -> lim_inf: 133.8500 | lim_sup: 133.8500 Límites IQR -> lim_inf: -1052.5712 | lim_sup: 1769.6188 Límites IQR -> lim_inf: 155.2400 | lim_sup: 160.5200 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: 42.0500 | lim_sup: 290.2700 Límites IQR -> lim_inf: -49.1437 | lim_sup: 173.8462 Límites IQR -> lim_inf: -724.6413 | lim_sup: 3002.7888 Límites IQR -> lim_inf: -487.5000 | lim_sup: 812.5000 Límites IQR -> lim_inf: -57.7125 | lim_sup: 96.1875 Límites IQR -> lim_inf: -189.0750 | lim_sup: 315.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -68.2000 | lim_sup: 1268.2400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -184.8750 | lim_sup: 308.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -297.9613 | lim_sup: 1889.5087 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -64.8375 | lim_sup: 108.0625 Límites IQR -> lim_inf: -596.8688 | lim_sup: 994.7813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.6500 | lim_sup: 2221.7900 Límites IQR -> lim_inf: -479.0550 | lim_sup: 1828.1850 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -185.2650 | lim_sup: 921.7950 Límites IQR -> lim_inf: -68.9100 | lim_sup: 756.4500 Límites IQR -> lim_inf: -376.6900 | lim_sup: 2466.7300 Límites IQR -> lim_inf: -314.3425 | lim_sup: 1616.7775 Límites IQR -> lim_inf: -104.5455 | lim_sup: 440.9091 Límites IQR -> lim_inf: -393.0832 | lim_sup: 1147.8306 Límites IQR -> lim_inf: -598.6154 | lim_sup: 997.6923 Límites IQR -> lim_inf: 81.4122 | lim_sup: 661.1743 Límites IQR -> lim_inf: -639.8491 | lim_sup: 2977.1544 Límites IQR -> lim_inf: -1594.0909 | lim_sup: 2656.8182 Límites IQR -> lim_inf: -1087.7578 | lim_sup: 3615.3343 Límites IQR -> lim_inf: 476.8801 | lim_sup: 1155.4634 Límites IQR -> lim_inf: 111.9452 | lim_sup: 2891.9159 Límites IQR -> lim_inf: -16228.6563 | lim_sup: 37479.6847 Límites IQR -> lim_inf: -32.6250 | lim_sup: 54.3750 Límites IQR -> lim_inf: 3879.7243 | lim_sup: 13888.4569 Límites IQR -> lim_inf: -4121.9771 | lim_sup: 22738.5584 Límites IQR -> lim_inf: -3244.7424 | lim_sup: 38409.6151 Límites IQR -> lim_inf: -3113.5801 | lim_sup: 9154.2309 Límites IQR -> lim_inf: 15312.1719 | lim_sup: 39048.4449 Límites IQR -> lim_inf: -54.1238 | lim_sup: 90.2063 Límites IQR -> lim_inf: 1382.1845 | lim_sup: 2670.2765 Límites IQR -> lim_inf: -1303.2129 | lim_sup: 5015.0321 Límites IQR -> lim_inf: 1432.0975 | lim_sup: 11059.4335 Límites IQR -> lim_inf: -1223.9190 | lim_sup: 2039.8650 Límites IQR -> lim_inf: 4944.2276 | lim_sup: 17072.2451 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: 1929.4137 | lim_sup: 3594.9397 Límites IQR -> lim_inf: -2060.9314 | lim_sup: 5833.4931 Límites IQR -> lim_inf: 1231.8040 | lim_sup: 9188.9240 Límites IQR -> lim_inf: -277.9665 | lim_sup: 463.2775 Límites IQR -> lim_inf: 901.7534 | lim_sup: 17453.0479 Límites IQR -> lim_inf: -490.7500 | lim_sup: 1472.2500 Límites IQR -> lim_inf: 847.3685 | lim_sup: 2637.3205 Límites IQR -> lim_inf: -270.0967 | lim_sup: 3109.2552 Límites IQR -> lim_inf: -379.6005 | lim_sup: 11192.7595 Límites IQR -> lim_inf: -612.2562 | lim_sup: 1321.7537 Límites IQR -> lim_inf: 3293.9313 | lim_sup: 10782.7048 Límites IQR -> lim_inf: 87.0000 | lim_sup: 87.0000 Límites IQR -> lim_inf: 818.3010 | lim_sup: 2556.0770 Límites IQR -> lim_inf: -983.8793 | lim_sup: 2760.0008 Límites IQR -> lim_inf: 1032.6935 | lim_sup: 5953.2295 Límites IQR -> lim_inf: -420.6432 | lim_sup: 1227.0743 Límites IQR -> lim_inf: 1165.8639 | lim_sup: 7647.2464 Límites IQR -> lim_inf: -675.0000 | lim_sup: 1125.0000 Límites IQR -> lim_inf: 1388.4615 | lim_sup: 2944.9095 Límites IQR -> lim_inf: -43.7501 | lim_sup: 2832.9554 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2922.6872 | lim_sup: 13339.3723 Límites IQR -> lim_inf: -1029.3187 | lim_sup: 2523.5312 Límites IQR -> lim_inf: 1978.1789 | lim_sup: 8143.0599 Límites IQR -> lim_inf: -29.9337 | lim_sup: 89.8012 Límites IQR -> lim_inf: 1687.4331 | lim_sup: 2450.4117 Límites IQR -> lim_inf: -45.1928 | lim_sup: 1352.6253 Límites IQR -> lim_inf: 144.9515 | lim_sup: 6555.9715 Límites IQR -> lim_inf: -253.5000 | lim_sup: 422.5000 Límites IQR -> lim_inf: 3520.6992 | lim_sup: 10776.9617 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 782.8240 | lim_sup: 2318.0400 Límites IQR -> lim_inf: -807.5751 | lim_sup: 2795.8769 Límites IQR -> lim_inf: 891.0318 | lim_sup: 7273.5058 Límites IQR -> lim_inf: -133.7625 | lim_sup: 222.9375 Límites IQR -> lim_inf: 5540.3007 | lim_sup: 13419.5598 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3355.4182 | lim_sup: 6557.5868 Límites IQR -> lim_inf: -411.1585 | lim_sup: 4957.0715 Límites IQR -> lim_inf: 3912.2582 | lim_sup: 14169.1523 Límites IQR -> lim_inf: 126.5000 | lim_sup: 762.5000 Límites IQR -> lim_inf: 13759.2056 | lim_sup: 26749.0381 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1990.6608 | lim_sup: 3966.6286 Límites IQR -> lim_inf: 112.4172 | lim_sup: 4552.1334 Límites IQR -> lim_inf: 1844.2677 | lim_sup: 9768.6538 Límites IQR -> lim_inf: -849.9307 | lim_sup: 1416.5512 Límites IQR -> lim_inf: 8319.5026 | lim_sup: 18270.2086 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -531.7321 | lim_sup: 886.2202 Límites IQR -> lim_inf: 46.3595 | lim_sup: 81.4643 Límites IQR -> lim_inf: -268.4196 | lim_sup: 447.3661 Límites IQR -> lim_inf: -473.2402 | lim_sup: 3731.0479 Límites IQR -> lim_inf: -234.8214 | lim_sup: 391.3690 Límites IQR -> lim_inf: -480.9929 | lim_sup: 1445.0690 Límites IQR -> lim_inf: -71.4286 | lim_sup: 119.0476 Límites IQR -> lim_inf: -206.6551 | lim_sup: 428.5521 Límites IQR -> lim_inf: -1436.2149 | lim_sup: 9793.1232 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -350.0000 | lim_sup: 583.3333 Límites IQR -> lim_inf: -1001.5086 | lim_sup: 2499.3747 Límites IQR -> lim_inf: -58.6857 | lim_sup: 222.1333 Límites IQR -> lim_inf: -301.2963 | lim_sup: 502.1604 Límites IQR -> lim_inf: -1330.0568 | lim_sup: 11094.4217 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1097.0873 | lim_sup: 2911.3892 Límites IQR -> lim_inf: 207.1973 | lim_sup: 1997.0438 Límites IQR -> lim_inf: 1047.1213 | lim_sup: 6255.8032 Límites IQR -> lim_inf: -502.0800 | lim_sup: 836.8000 Límites IQR -> lim_inf: 2018.7014 | lim_sup: 10241.7134 Límites IQR -> lim_inf: -102.6786 | lim_sup: 171.1310 Límites IQR -> lim_inf: -578.1107 | lim_sup: 963.5179 Límites IQR -> lim_inf: -11.6488 | lim_sup: 165.4464 Límites IQR -> lim_inf: -151.3107 | lim_sup: 252.1845 Límites IQR -> lim_inf: -938.5533 | lim_sup: 6303.7348 Límites IQR -> lim_inf: -128.5714 | lim_sup: 214.2857 Límites IQR -> lim_inf: -222.6497 | lim_sup: 529.1003 Límites IQR -> lim_inf: -25.6500 | lim_sup: 42.7500 Límites IQR -> lim_inf: -152.6250 | lim_sup: 254.3750 Límites IQR -> lim_inf: -507.9711 | lim_sup: 3976.7313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -575.8676 | lim_sup: 1107.2824 Límites IQR -> lim_inf: -43.1464 | lim_sup: 71.9107 Límites IQR -> lim_inf: -147.5786 | lim_sup: 245.9643 Límites IQR -> lim_inf: 931.8313 | lim_sup: 4156.5860 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1115.5605 | lim_sup: 2443.4965 Límites IQR -> lim_inf: -379.1206 | lim_sup: 2578.1874 Límites IQR -> lim_inf: 1026.6622 | lim_sup: 7566.9043 Límites IQR -> lim_inf: -400.6733 | lim_sup: 1084.4388 Límites IQR -> lim_inf: 4099.4649 | lim_sup: 8884.7644 Límites IQR -> lim_inf: -250.0000 | lim_sup: 416.6667 Límites IQR -> lim_inf: -508.8571 | lim_sup: 1362.3810 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 99.0357 | lim_sup: 99.0357 Límites IQR -> lim_inf: -278.3741 | lim_sup: 463.9568 Límites IQR -> lim_inf: 749.4583 | lim_sup: 9738.4060 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1653.0877 | lim_sup: 3409.1707 Límites IQR -> lim_inf: -984.4246 | lim_sup: 4521.2084 Límites IQR -> lim_inf: -330.2837 | lim_sup: 9853.6902 Límites IQR -> lim_inf: -191.5043 | lim_sup: 319.1738 Límites IQR -> lim_inf: 7220.7356 | lim_sup: 12469.6081 Límites IQR -> lim_inf: -625.0000 | lim_sup: 1041.6667 Límites IQR -> lim_inf: -1686.2208 | lim_sup: 4703.4268 Límites IQR -> lim_inf: -22.7702 | lim_sup: 244.0583 Límites IQR -> lim_inf: -572.0863 | lim_sup: 1042.3661 Límites IQR -> lim_inf: -684.8548 | lim_sup: 16325.5167 Límites IQR -> lim_inf: -594.6429 | lim_sup: 991.0714 Límites IQR -> lim_inf: -787.6161 | lim_sup: 3693.7458 Límites IQR -> lim_inf: -5.6095 | lim_sup: 233.7619 Límites IQR -> lim_inf: -625.7500 | lim_sup: 1282.0500 Límites IQR -> lim_inf: -2817.5985 | lim_sup: 10504.7372 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1577.0925 | lim_sup: 2911.3965 Límites IQR -> lim_inf: -1530.5037 | lim_sup: 3993.5688 Límites IQR -> lim_inf: 745.0825 | lim_sup: 4168.5825 Límites IQR -> lim_inf: -219.0000 | lim_sup: 365.0000 Límites IQR -> lim_inf: 537.0208 | lim_sup: 14198.2033 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1856.5632 | lim_sup: 3610.6813 Límites IQR -> lim_inf: -1651.7155 | lim_sup: 5665.5675 Límites IQR -> lim_inf: 1017.6397 | lim_sup: 6245.5258 Límites IQR -> lim_inf: -647.4187 | lim_sup: 1225.0312 Límites IQR -> lim_inf: 2979.3320 | lim_sup: 13280.6250 Límites IQR -> lim_inf: 311.2200 | lim_sup: 311.2200 Límites IQR -> lim_inf: 4430.5024 | lim_sup: 8673.5562 Límites IQR -> lim_inf: -1141.8087 | lim_sup: 10358.5752 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4042.1347 | lim_sup: 17551.8927 Límites IQR -> lim_inf: -1245.8925 | lim_sup: 2076.4875 Límites IQR -> lim_inf: 16082.0059 | lim_sup: 34867.6879 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1362.8518 | lim_sup: 2833.0137 Límites IQR -> lim_inf: -1319.2276 | lim_sup: 3994.2034 Límites IQR -> lim_inf: 67.2367 | lim_sup: 10421.3732 Límites IQR -> lim_inf: -734.6250 | lim_sup: 1224.3750 Límites IQR -> lim_inf: 3558.5551 | lim_sup: 16020.8221 Límites IQR -> lim_inf: 1450.0000 | lim_sup: 1450.0000 Límites IQR -> lim_inf: 45.2088 | lim_sup: 64.4748 Límites IQR -> lim_inf: -747.5625 | lim_sup: 1245.9375 Límites IQR -> lim_inf: 327.9425 | lim_sup: 1143.0745 Límites IQR -> lim_inf: 172.0000 | lim_sup: 172.0000 Límites IQR -> lim_inf: 313.3101 | lim_sup: 759.1261 Límites IQR -> lim_inf: -441.9130 | lim_sup: 736.5217 Límites IQR -> lim_inf: -271.8258 | lim_sup: 853.2880 Límites IQR -> lim_inf: 156.7969 | lim_sup: 395.0295 Límites IQR -> lim_inf: -148.2348 | lim_sup: 950.8838 Límites IQR -> lim_inf: 1348.4384 | lim_sup: 3719.8261 Límites IQR -> lim_inf: -240.1442 | lim_sup: 477.1635 Límites IQR -> lim_inf: -645.8165 | lim_sup: 2048.6971 Límites IQR -> lim_inf: 496.8015 | lim_sup: 496.8015 Límites IQR -> lim_inf: 203.0400 | lim_sup: 570.1145 Límites IQR -> lim_inf: 235.1444 | lim_sup: 1527.0684 Límites IQR -> lim_inf: 436.0446 | lim_sup: 13090.3946 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -361.3470 | lim_sup: 602.2449 Límites IQR -> lim_inf: -156.2308 | lim_sup: 260.3846 Límites IQR -> lim_inf: -232.6886 | lim_sup: 387.8144 Límites IQR -> lim_inf: 81.7881 | lim_sup: 1995.4976 Límites IQR -> lim_inf: -392.8500 | lim_sup: 654.7500 Límites IQR -> lim_inf: -456.6198 | lim_sup: 966.0293 Límites IQR -> lim_inf: 147.0738 | lim_sup: 324.1618 Límites IQR -> lim_inf: -240.3984 | lim_sup: 638.2680 Límites IQR -> lim_inf: -1293.8924 | lim_sup: 8289.4049 Límites IQR -> lim_inf: -51.7308 | lim_sup: 660.5769 Límites IQR -> lim_inf: -975.2411 | lim_sup: 2237.2565 Límites IQR -> lim_inf: 266.5169 | lim_sup: 720.8283 Límites IQR -> lim_inf: 218.2691 | lim_sup: 2226.7191 Límites IQR -> lim_inf: -2600.6975 | lim_sup: 22225.3569 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: -317.3857 | lim_sup: 898.2101 Límites IQR -> lim_inf: 184.9662 | lim_sup: 493.8203 Límites IQR -> lim_inf: -32.6466 | lim_sup: 1096.7546 Límites IQR -> lim_inf: 1767.9101 | lim_sup: 6562.0651 Límites IQR -> lim_inf: -165.4196 | lim_sup: 521.8531 Límites IQR -> lim_inf: -2271.0004 | lim_sup: 16503.1591 Límites IQR -> lim_inf: 4688.2396 | lim_sup: 16561.8569 Límites IQR -> lim_inf: -663.3813 | lim_sup: 1105.6356 Límites IQR -> lim_inf: 14548.3093 | lim_sup: 45908.7788 Límites IQR -> lim_inf: -180.0000 | lim_sup: 300.0000 Límites IQR -> lim_inf: 1386.7423 | lim_sup: 3518.0122 Límites IQR -> lim_inf: -2207.6681 | lim_sup: 7514.8671 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -174.4425 | lim_sup: 9726.8175 Límites IQR -> lim_inf: -1046.3391 | lim_sup: 1743.8984 Límites IQR -> lim_inf: 7647.9629 | lim_sup: 16895.0409 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -643.4734 | lim_sup: 3075.6351 Límites IQR -> lim_inf: 2769.2167 | lim_sup: 3698.5007 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1379.7027 | lim_sup: 12190.9488 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -1781.7348 | lim_sup: 5796.4597 Límites IQR -> lim_inf: 3076.7087 | lim_sup: 12175.9071 Límites IQR -> lim_inf: -32.1450 | lim_sup: 53.5750 Límites IQR -> lim_inf: 2762.2295 | lim_sup: 34240.1895 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -315.4250 | lim_sup: 706.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1288.6137 | lim_sup: 4197.9762 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -93.2625 | lim_sup: 155.4375 Límites IQR -> lim_inf: -327.7500 | lim_sup: 546.2500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 29.2800 | lim_sup: 29.2800 Límites IQR -> lim_inf: -1683.8350 | lim_sup: 4127.2050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.4950 | lim_sup: 75.8250 Límites IQR -> lim_inf: -208.1025 | lim_sup: 346.8375 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 65.0000 | lim_sup: 65.0000 Límites IQR -> lim_inf: -1451.5075 | lim_sup: 3840.3725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -366.2437 | lim_sup: 610.4062 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 209.5000 | lim_sup: 209.5000 Límites IQR -> lim_inf: -301.8200 | lim_sup: 1556.5800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -57.7125 | lim_sup: 96.1875 Límites IQR -> lim_inf: -356.5987 | lim_sup: 594.3312 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -1144.0300 | lim_sup: 2920.1100 Límites IQR -> lim_inf: 46.1538 | lim_sup: 46.1538 Límites IQR -> lim_inf: -404.1346 | lim_sup: 673.5577 Límites IQR -> lim_inf: 357.6923 | lim_sup: 388.4615 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -665.4929 | lim_sup: 1638.1685 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -790.2415 | lim_sup: 1979.5531 Límites IQR -> lim_inf: 2813.5907 | lim_sup: 7864.9377 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1425.6927 | lim_sup: 10370.5187 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -1150.8423 | lim_sup: 3991.4038 Límites IQR -> lim_inf: 3046.6839 | lim_sup: 4229.6936 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3199.6594 | lim_sup: 9481.1946 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -682.5000 | lim_sup: 1137.5000 Límites IQR -> lim_inf: -431.9400 | lim_sup: 857.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -191.9837 | lim_sup: 2286.0462 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -395.1975 | lim_sup: 658.6625 Límites IQR -> lim_inf: -192.0000 | lim_sup: 320.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -489.2762 | lim_sup: 2002.3937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -368.3663 | lim_sup: 613.9438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -665.3762 | lim_sup: 2208.2337 Límites IQR -> lim_inf: -112.6125 | lim_sup: 517.6875 Límites IQR -> lim_inf: -78.0000 | lim_sup: 130.0000 Límites IQR -> lim_inf: -277.4775 | lim_sup: 462.4625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -84.0713 | lim_sup: 1791.5788 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -303.3000 | lim_sup: 505.5000 Límites IQR -> lim_inf: -368.0250 | lim_sup: 613.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -816.5388 | lim_sup: 3299.5113 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -422.1696 | lim_sup: 703.6161 Límites IQR -> lim_inf: -619.2940 | lim_sup: 1431.0440 Límites IQR -> lim_inf: -35.7143 | lim_sup: 59.5238 Límites IQR -> lim_inf: -244.5455 | lim_sup: 407.5759 Límites IQR -> lim_inf: 244.6000 | lim_sup: 11217.7238 Límites IQR -> lim_inf: -605.2524 | lim_sup: 1667.8146 Límites IQR -> lim_inf: -117.8481 | lim_sup: 271.7469 Límites IQR -> lim_inf: 1192.9898 | lim_sup: 5292.4215 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -461.6662 | lim_sup: 913.4438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -388.0500 | lim_sup: 2427.6300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -315.0000 | lim_sup: 685.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -53.6850 | lim_sup: 2800.2750 Límites IQR -> lim_inf: -509.8509 | lim_sup: 1832.3797 Límites IQR -> lim_inf: -251.6449 | lim_sup: 450.7551 Límites IQR -> lim_inf: -225.5266 | lim_sup: 7897.3500 Límites IQR -> lim_inf: 290.0000 | lim_sup: 290.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -538.3800 | lim_sup: 897.3000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -408.5000 | lim_sup: 1225.5000 Límites IQR -> lim_inf: -653.2400 | lim_sup: 2886.3200 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -269.6250 | lim_sup: 449.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -343.6137 | lim_sup: 2363.8162 Límites IQR -> lim_inf: -84.0000 | lim_sup: 140.0000 Límites IQR -> lim_inf: -1558.5000 | lim_sup: 2597.5000 Límites IQR -> lim_inf: -323.1012 | lim_sup: 3566.3487 Límites IQR -> lim_inf: 598.5100 | lim_sup: 1878.5100 Límites IQR -> lim_inf: -931.6350 | lim_sup: 1552.7250 Límites IQR -> lim_inf: -1600.5275 | lim_sup: 9320.2325 Límites IQR -> lim_inf: -711.7949 | lim_sup: 2079.8117 Límites IQR -> lim_inf: -205.4610 | lim_sup: 342.4350 Límites IQR -> lim_inf: 1187.5395 | lim_sup: 6233.8782 Límites IQR -> lim_inf: -5.6787 | lim_sup: 106.9112 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -34.1250 | lim_sup: 56.8750 Límites IQR -> lim_inf: 0.2263 | lim_sup: 54.5963 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -87.7028 | lim_sup: 831.6353 Límites IQR -> lim_inf: -429.3573 | lim_sup: 3822.7142 Límites IQR -> lim_inf: 3066.5295 | lim_sup: 8719.1015 Límites IQR -> lim_inf: -500.4563 | lim_sup: 834.0938 Límites IQR -> lim_inf: 9475.8656 | lim_sup: 21867.5636 Límites IQR -> lim_inf: 106.6455 | lim_sup: 504.7545 Límites IQR -> lim_inf: -373.6364 | lim_sup: 804.5455 Límites IQR -> lim_inf: -1026.2261 | lim_sup: 4771.2011 Límites IQR -> lim_inf: 4351.8182 | lim_sup: 5655.8182 Límites IQR -> lim_inf: -607.8886 | lim_sup: 1013.1477 Límites IQR -> lim_inf: 9577.6420 | lim_sup: 12565.8330 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -864.4050 | lim_sup: 1440.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -328.9688 | lim_sup: 2422.0813 Límites IQR -> lim_inf: 149.2545 | lim_sup: 415.7273 Límites IQR -> lim_inf: 181.8182 | lim_sup: 181.8182 Límites IQR -> lim_inf: -1274.4170 | lim_sup: 5399.3193 Límites IQR -> lim_inf: -9007.0509 | lim_sup: 17250.2000 Límites IQR -> lim_inf: -463.6364 | lim_sup: 772.7273 Límites IQR -> lim_inf: 4782.1682 | lim_sup: 6759.3136 Límites IQR -> lim_inf: -13.6818 | lim_sup: 463.4091 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2219.1852 | lim_sup: 5584.1330 Límites IQR -> lim_inf: 5639.3091 | lim_sup: 6364.4000 Límites IQR -> lim_inf: -284.6455 | lim_sup: 474.4091 Límites IQR -> lim_inf: 6571.5250 | lim_sup: 11372.3250 Límites IQR -> lim_inf: 280.0000 | lim_sup: 280.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -336.2212 | lim_sup: 560.3687 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -100.5237 | lim_sup: 2385.1062 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -473.3550 | lim_sup: 788.9250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -470.2400 | lim_sup: 2392.1800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1703.7155 | lim_sup: 3537.8795 Límites IQR -> lim_inf: -1140.2244 | lim_sup: 3258.7001 Límites IQR -> lim_inf: 1983.8699 | lim_sup: 10250.1124 Límites IQR -> lim_inf: -47.2500 | lim_sup: 78.7500 Límites IQR -> lim_inf: 5984.0782 | lim_sup: 18494.1223 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -339.6045 | lim_sup: 566.0074 Límites IQR -> lim_inf: -899.0937 | lim_sup: 2008.9753 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -107.1429 | lim_sup: 178.5714 Límites IQR -> lim_inf: -513.8571 | lim_sup: 856.4286 Límites IQR -> lim_inf: 581.4357 | lim_sup: 1279.1167 Límites IQR -> lim_inf: 127.5500 | lim_sup: 332.9682 Límites IQR -> lim_inf: -250.0000 | lim_sup: 659.0909 Límites IQR -> lim_inf: -2077.6591 | lim_sup: 4261.4318 Límites IQR -> lim_inf: 4194.8614 | lim_sup: 5732.8432 Límites IQR -> lim_inf: -421.0023 | lim_sup: 701.6705 Límites IQR -> lim_inf: 7644.1318 | lim_sup: 9967.9318 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 868.6400 | lim_sup: 868.6400 Límites IQR -> lim_inf: -57.6562 | lim_sup: 338.5938 Límites IQR -> lim_inf: 280.0000 | lim_sup: 280.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -353.4600 | lim_sup: 589.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -324.4350 | lim_sup: 2036.9450 Límites IQR -> lim_inf: -2146.3950 | lim_sup: 3577.3250 Límites IQR -> lim_inf: -958.3000 | lim_sup: 2962.3400 Límites IQR -> lim_inf: -1097.2050 | lim_sup: 4614.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 124.7625 | lim_sup: 1772.0625 Límites IQR -> lim_inf: -325.3312 | lim_sup: 1772.1388 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.0412 | lim_sup: 250.0687 Límites IQR -> lim_inf: -5404.5975 | lim_sup: 14145.0825 Límites IQR -> lim_inf: -212.2612 | lim_sup: 353.7687 Límites IQR -> lim_inf: -713.9350 | lim_sup: 2174.4250 Límites IQR -> lim_inf: -616.8500 | lim_sup: 2039.0700 Límites IQR -> lim_inf: -297.7950 | lim_sup: 2040.4850 Límites IQR -> lim_inf: -191.4850 | lim_sup: 2138.1550 Límites IQR -> lim_inf: -497.2350 | lim_sup: 1678.2450 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -392.8571 | lim_sup: 654.7619 Límites IQR -> lim_inf: -1275.0485 | lim_sup: 3353.7967 Límites IQR -> lim_inf: -28.8571 | lim_sup: 175.7714 Límites IQR -> lim_inf: -400.1149 | lim_sup: 702.4137 Límites IQR -> lim_inf: -20.8557 | lim_sup: 11415.9801 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -524.7597 | lim_sup: 2040.0463 Límites IQR -> lim_inf: 47.8786 | lim_sup: 47.8786 Límites IQR -> lim_inf: -220.4131 | lim_sup: 577.5012 Límites IQR -> lim_inf: 417.5991 | lim_sup: 9809.0539 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -630.3571 | lim_sup: 1050.5952 Límites IQR -> lim_inf: -1038.3318 | lim_sup: 3367.4610 Límites IQR -> lim_inf: -96.3000 | lim_sup: 288.1762 Límites IQR -> lim_inf: -590.0068 | lim_sup: 1224.6146 Límites IQR -> lim_inf: 795.4863 | lim_sup: 9403.5720 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -494.0476 | lim_sup: 1077.3810 Límites IQR -> lim_inf: -1485.9518 | lim_sup: 4418.5196 Límites IQR -> lim_inf: -55.5429 | lim_sup: 220.2476 Límites IQR -> lim_inf: -560.8155 | lim_sup: 1210.0417 Límites IQR -> lim_inf: 1419.3619 | lim_sup: 10711.2667 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -141.5812 | lim_sup: 235.9687 Límites IQR -> lim_inf: -598.8155 | lim_sup: 1631.1036 Límites IQR -> lim_inf: -11.9696 | lim_sup: 83.7875 Límites IQR -> lim_inf: -156.2554 | lim_sup: 374.7113 Límites IQR -> lim_inf: -588.0509 | lim_sup: 6137.9182 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -207.1429 | lim_sup: 345.2381 Límites IQR -> lim_inf: -535.4149 | lim_sup: 1337.6232 Límites IQR -> lim_inf: -71.8179 | lim_sup: 119.6964 Límites IQR -> lim_inf: -373.8920 | lim_sup: 623.1533 Límites IQR -> lim_inf: -857.1613 | lim_sup: 7253.6673 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -178.5714 | lim_sup: 297.6190 Límites IQR -> lim_inf: -538.2012 | lim_sup: 1361.4750 Límites IQR -> lim_inf: -71.8179 | lim_sup: 119.6964 Límites IQR -> lim_inf: -229.6482 | lim_sup: 382.7470 Límites IQR -> lim_inf: 47.4565 | lim_sup: 5431.7470 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -564.2857 | lim_sup: 940.4762 Límites IQR -> lim_inf: -862.8833 | lim_sup: 2392.1024 Límites IQR -> lim_inf: -11.9696 | lim_sup: 83.7875 Límites IQR -> lim_inf: -196.2146 | lim_sup: 476.9116 Límites IQR -> lim_inf: 192.5571 | lim_sup: 6978.8714 Límites IQR -> lim_inf: -107.7804 | lim_sup: 179.6339 Límites IQR -> lim_inf: -850.9670 | lim_sup: 1418.2783 Límites IQR -> lim_inf: -75.6643 | lim_sup: 126.1071 Límites IQR -> lim_inf: -165.2688 | lim_sup: 275.4479 Límites IQR -> lim_inf: 876.6982 | lim_sup: 4624.7792 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -464.2050 | lim_sup: 2241.4550 Límites IQR -> lim_inf: -583.4787 | lim_sup: 2036.5313 Límites IQR -> lim_inf: -705.8113 | lim_sup: 2488.8988 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -423.6300 | lim_sup: 706.0500 Límites IQR -> lim_inf: -307.2250 | lim_sup: 1965.2950 Límites IQR -> lim_inf: -28.1250 | lim_sup: 46.8750 Límites IQR -> lim_inf: -126.0000 | lim_sup: 210.0000 Límites IQR -> lim_inf: -160.0275 | lim_sup: 2094.8725 Límites IQR -> lim_inf: -427.0512 | lim_sup: 1996.3787 Límites IQR -> lim_inf: -1481.5750 | lim_sup: 4965.1650 Límites IQR -> lim_inf: -1598.7588 | lim_sup: 3280.1713 Límites IQR -> lim_inf: -405.3575 | lim_sup: 921.0425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -142.1250 | lim_sup: 236.8750 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -351.4988 | lim_sup: 1963.8713 Límites IQR -> lim_inf: 50.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -146.0250 | lim_sup: 243.3750 Límites IQR -> lim_inf: -423.6638 | lim_sup: 2430.0463 Límites IQR -> lim_inf: -1046.1800 | lim_sup: 3225.5600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -156.1800 | lim_sup: 260.3000 Límites IQR -> lim_inf: -296.8500 | lim_sup: 645.3100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1066.3387 | lim_sup: 3901.0312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.8850 | lim_sup: 111.4750 Límites IQR -> lim_inf: -157.0500 | lim_sup: 261.7500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -771.4463 | lim_sup: 4061.9038 Límites IQR -> lim_inf: -66.1000 | lim_sup: 306.2800 Límites IQR -> lim_inf: -538.9900 | lim_sup: 2348.1300 Límites IQR -> lim_inf: -1561.0213 | lim_sup: 3873.1687 Límites IQR -> lim_inf: -157.1000 | lim_sup: 614.3200 Límites IQR -> lim_inf: -954.5750 | lim_sup: 3043.7850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -396.3750 | lim_sup: 660.6250 Límites IQR -> lim_inf: 55.7600 | lim_sup: 761.6000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -127.3862 | lim_sup: 688.2037 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -208.5000 | lim_sup: 347.5000 Límites IQR -> lim_inf: -926.7375 | lim_sup: 1544.5625 Límites IQR -> lim_inf: 624.2000 | lim_sup: 624.2000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -353.8575 | lim_sup: 1618.6625 Límites IQR -> lim_inf: 214.0000 | lim_sup: 214.0000 Límites IQR -> lim_inf: -916.7250 | lim_sup: 1527.8750 Límites IQR -> lim_inf: -101.7763 | lim_sup: 587.3138 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -439.1063 | lim_sup: 731.8438 Límites IQR -> lim_inf: -164.4675 | lim_sup: 1161.5525 Límites IQR -> lim_inf: -336.8363 | lim_sup: 561.3937 Límites IQR -> lim_inf: -528.9150 | lim_sup: 881.5250 Límites IQR -> lim_inf: -302.8825 | lim_sup: 1880.3375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -67.6500 | lim_sup: 112.7500 Límites IQR -> lim_inf: -298.1250 | lim_sup: 535.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -846.1987 | lim_sup: 1540.3312 Límites IQR -> lim_inf: -685.6825 | lim_sup: 2415.1375 Límites IQR -> lim_inf: 127.5500 | lim_sup: 332.9682 Límites IQR -> lim_inf: -373.6364 | lim_sup: 804.5455 Límites IQR -> lim_inf: -2168.5216 | lim_sup: 5656.4693 Límites IQR -> lim_inf: -7635.6364 | lim_sup: 13498.9091 Límites IQR -> lim_inf: -255.6818 | lim_sup: 426.1364 Límites IQR -> lim_inf: 11468.9705 | lim_sup: 18900.6977 Límites IQR -> lim_inf: -487.5000 | lim_sup: 812.5000 Límites IQR -> lim_inf: -54.1200 | lim_sup: 90.2000 Límites IQR -> lim_inf: -100.3988 | lim_sup: 167.3313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -455.3863 | lim_sup: 2777.1638 Límites IQR -> lim_inf: -496.5375 | lim_sup: 827.5625 Límites IQR -> lim_inf: -57.7125 | lim_sup: 96.1875 Límites IQR -> lim_inf: -248.6812 | lim_sup: 414.4688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -313.6313 | lim_sup: 522.7188 Límites IQR -> lim_inf: -934.6000 | lim_sup: 3049.3200 Límites IQR -> lim_inf: -902.5950 | lim_sup: 2723.6850 Límites IQR -> lim_inf: -1483.8500 | lim_sup: 3841.2700 Límites IQR -> lim_inf: -1515.0700 | lim_sup: 4175.8900 Límites IQR -> lim_inf: -1436.9800 | lim_sup: 4222.6200 Límites IQR -> lim_inf: 7264.5945 | lim_sup: 7264.5945 Límites IQR -> lim_inf: 1700.0000 | lim_sup: 1700.0000 Límites IQR -> lim_inf: 1266.1182 | lim_sup: 3819.3364 Límites IQR -> lim_inf: -1070.6317 | lim_sup: 8336.3698 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3066.6022 | lim_sup: 18301.5043 Límites IQR -> lim_inf: 255.2345 | lim_sup: 3605.7465 Límites IQR -> lim_inf: 9254.0573 | lim_sup: 32625.3933 Límites IQR -> lim_inf: 233.1300 | lim_sup: 973.1300 Límites IQR -> lim_inf: 182.6450 | lim_sup: 1553.4050 Límites IQR -> lim_inf: 1120.8275 | lim_sup: 1778.8075 Límites IQR -> lim_inf: -2363.7150 | lim_sup: 3939.5250 Límites IQR -> lim_inf: 3.3863 | lim_sup: 997.0162 Límites IQR -> lim_inf: 1072.6850 | lim_sup: 1499.9650 Límites IQR -> lim_inf: -544.6738 | lim_sup: 1126.6562 Límites IQR -> lim_inf: -1904.4250 | lim_sup: 5115.2950 Límites IQR -> lim_inf: -907.8900 | lim_sup: 2639.9500 Límites IQR -> lim_inf: -101.1400 | lim_sup: 3397.5200 Límites IQR -> lim_inf: -144.2308 | lim_sup: 240.3846 Límites IQR -> lim_inf: -334.5577 | lim_sup: 557.5962 Límites IQR -> lim_inf: -1143.0158 | lim_sup: 2184.3879 Límites IQR -> lim_inf: -75.5466 | lim_sup: 547.9264 Límites IQR -> lim_inf: 113.3632 | lim_sup: 2064.0101 Límites IQR -> lim_inf: -650.3625 | lim_sup: 2186.4975 Límites IQR -> lim_inf: -671.0800 | lim_sup: 2072.8400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -283.8475 | lim_sup: 2488.6125 Límites IQR -> lim_inf: -565.2500 | lim_sup: 2918.4500 Límites IQR -> lim_inf: -128.6014 | lim_sup: 460.4895 Límites IQR -> lim_inf: -4645.5143 | lim_sup: 13933.5710 Límites IQR -> lim_inf: 342.3077 | lim_sup: 373.0769 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 560.6108 | lim_sup: 48201.5054 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -341.2500 | lim_sup: 568.7500 Límites IQR -> lim_inf: -473.3625 | lim_sup: 788.9375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -130.1400 | lim_sup: 216.9000 Límites IQR -> lim_inf: -359.1137 | lim_sup: 1623.0963 Límites IQR -> lim_inf: -0.3465 | lim_sup: 0.5775 Límites IQR -> lim_inf: -22.5000 | lim_sup: 37.5000 Límites IQR -> lim_inf: -198.7500 | lim_sup: 331.2500 Límites IQR -> lim_inf: -412.2525 | lim_sup: 2248.2875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -261.7950 | lim_sup: 436.3250 Límites IQR -> lim_inf: 315.8900 | lim_sup: 315.8900 Límites IQR -> lim_inf: -773.8700 | lim_sup: 3828.4100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -251.3850 | lim_sup: 418.9750 Límites IQR -> lim_inf: -698.8500 | lim_sup: 1164.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -192.0000 | lim_sup: 320.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1349.4500 | lim_sup: 3472.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -266.1750 | lim_sup: 443.6250 Límites IQR -> lim_inf: -196.5525 | lim_sup: 327.5875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -268.6800 | lim_sup: 447.8000 Límites IQR -> lim_inf: -332.1337 | lim_sup: 553.5562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1019.4962 | lim_sup: 4522.5737 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -361.9950 | lim_sup: 603.3250 Límites IQR -> lim_inf: -89.0437 | lim_sup: 148.4062 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -599.5962 | lim_sup: 2474.6937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -177.4350 | lim_sup: 295.7250 Límites IQR -> lim_inf: -140.8200 | lim_sup: 234.7000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.7300 | lim_sup: 121.7300 Límites IQR -> lim_inf: -348.5975 | lim_sup: 1588.0625 Límites IQR -> lim_inf: -1281.0000 | lim_sup: 2135.0000 Límites IQR -> lim_inf: -177.4350 | lim_sup: 295.7250 Límites IQR -> lim_inf: -253.5863 | lim_sup: 422.6438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -948.0725 | lim_sup: 2625.9875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -641.5200 | lim_sup: 1069.2000 Límites IQR -> lim_inf: -172.3425 | lim_sup: 287.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 43.8187 | lim_sup: 47.8688 Límites IQR -> lim_inf: 2749.4400 | lim_sup: 2749.4400 Límites IQR -> lim_inf: -511.0100 | lim_sup: 3636.1700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -375.0000 | lim_sup: 625.0000 Límites IQR -> lim_inf: -438.4575 | lim_sup: 730.7625 Límites IQR -> lim_inf: -175.0000 | lim_sup: 525.0000 Límites IQR -> lim_inf: 103.0000 | lim_sup: 103.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1492.8162 | lim_sup: 4426.9337 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -221.7900 | lim_sup: 369.6500 Límites IQR -> lim_inf: -148.5000 | lim_sup: 247.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -18.7500 | lim_sup: 31.2500 Límites IQR -> lim_inf: -952.2013 | lim_sup: 2655.9688 Límites IQR -> lim_inf: 650.0000 | lim_sup: 650.0000 Límites IQR -> lim_inf: -341.9400 | lim_sup: 569.9000 Límites IQR -> lim_inf: -148.5975 | lim_sup: 247.6625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -41.2500 | lim_sup: 68.7500 Límites IQR -> lim_inf: -223.3500 | lim_sup: 372.2500 Límites IQR -> lim_inf: 103.0000 | lim_sup: 103.0000 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -687.1450 | lim_sup: 2693.1550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -646.4175 | lim_sup: 1077.3625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 103.0000 | lim_sup: 103.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 387.1100 | lim_sup: 1533.6700 Límites IQR -> lim_inf: -515.8900 | lim_sup: 1704.7100 Límites IQR -> lim_inf: 2067.0100 | lim_sup: 2067.0100 Límites IQR -> lim_inf: 1913.0400 | lim_sup: 2069.0400 Límites IQR -> lim_inf: -109.7850 | lim_sup: 690.1950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -479.4000 | lim_sup: 799.0000 Límites IQR -> lim_inf: -417.1350 | lim_sup: 921.2250 Límites IQR -> lim_inf: -908.5050 | lim_sup: 1514.1750 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -1559.3125 | lim_sup: 6381.0875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -180.0000 | lim_sup: 300.0000 Límites IQR -> lim_inf: -105.9375 | lim_sup: 176.5625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -434.6600 | lim_sup: 2085.3800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -153.3375 | lim_sup: 255.5625 Límites IQR -> lim_inf: -104.5875 | lim_sup: 174.3125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -205.0813 | lim_sup: 1555.0888 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.5000 | lim_sup: 337.5000 Límites IQR -> lim_inf: -278.5425 | lim_sup: 464.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -455.3037 | lim_sup: 1980.2662 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.7062 | lim_sup: 337.8438 Límites IQR -> lim_inf: -473.0325 | lim_sup: 788.3875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 33.9500 | lim_sup: 33.9500 Límites IQR -> lim_inf: -344.3487 | lim_sup: 1912.4212 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -197.7113 | lim_sup: 329.5188 Límites IQR -> lim_inf: -157.3575 | lim_sup: 262.2625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -353.8538 | lim_sup: 1832.1562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.7062 | lim_sup: 337.8438 Límites IQR -> lim_inf: -549.2250 | lim_sup: 915.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -333.4500 | lim_sup: 555.7500 Límites IQR -> lim_inf: -433.2150 | lim_sup: 722.0250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -96.0000 | lim_sup: 160.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -557.2738 | lim_sup: 2493.0363 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -153.7500 | lim_sup: 256.2500 Límites IQR -> lim_inf: -431.0325 | lim_sup: 718.3875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -412.5000 | lim_sup: 687.5000 Límites IQR -> lim_inf: 208.0000 | lim_sup: 208.0000 Límites IQR -> lim_inf: -314.8125 | lim_sup: 524.6875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 155.6300 | lim_sup: 155.6300 Límites IQR -> lim_inf: 25.5000 | lim_sup: 93.5000 Límites IQR -> lim_inf: 359.4700 | lim_sup: 1414.8300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -286.5000 | lim_sup: 477.5000 Límites IQR -> lim_inf: -251.1825 | lim_sup: 418.6375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -63.6938 | lim_sup: 106.1563 Límites IQR -> lim_inf: -78.3675 | lim_sup: 1276.4925 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -203.3363 | lim_sup: 338.8938 Límites IQR -> lim_inf: -5.2500 | lim_sup: 8.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -291.6900 | lim_sup: 2202.5100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -187.0312 | lim_sup: 311.7188 Límites IQR -> lim_inf: -118.2900 | lim_sup: 197.1500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1802.8787 | lim_sup: 4659.1912 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -293.0775 | lim_sup: 488.4625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -437.0887 | lim_sup: 2100.4012 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -161.2500 | lim_sup: 268.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -223.1362 | lim_sup: 371.8937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 154.0000 | lim_sup: 154.0000 Límites IQR -> lim_inf: -128.7325 | lim_sup: 1536.0275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -153.7500 | lim_sup: 256.2500 Límites IQR -> lim_inf: -247.5000 | lim_sup: 412.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -558.8213 | lim_sup: 2041.3088 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -104.0625 | lim_sup: 173.4375 Límites IQR -> lim_inf: -152.0362 | lim_sup: 253.3938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -267.0438 | lim_sup: 1597.8063 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -198.7500 | lim_sup: 331.2500 Límites IQR -> lim_inf: -103.6875 | lim_sup: 172.8125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -404.8725 | lim_sup: 1929.5475 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -217.6687 | lim_sup: 362.7812 Límites IQR -> lim_inf: -220.1850 | lim_sup: 366.9750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -367.3188 | lim_sup: 2443.8913 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -195.0000 | lim_sup: 325.0000 Límites IQR -> lim_inf: -376.8750 | lim_sup: 628.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -224.5050 | lim_sup: 1894.0750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -204.6112 | lim_sup: 341.0187 Límites IQR -> lim_inf: -182.3850 | lim_sup: 303.9750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 160.0000 | lim_sup: 160.0000 Límites IQR -> lim_inf: -241.3288 | lim_sup: 1984.9013 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -186.7500 | lim_sup: 311.2500 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -226.3862 | lim_sup: 1393.2437 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: -234.6825 | lim_sup: 391.1375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -83.3563 | lim_sup: 1421.8338 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -110.1750 | lim_sup: 183.6250 Límites IQR -> lim_inf: -423.0000 | lim_sup: 705.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -298.5188 | lim_sup: 2406.2712 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -108.3375 | lim_sup: 180.5625 Límites IQR -> lim_inf: -533.4000 | lim_sup: 889.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -76.4625 | lim_sup: 127.4375 Límites IQR -> lim_inf: -415.7838 | lim_sup: 2276.6063 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -732.3900 | lim_sup: 1220.6500 Límites IQR -> lim_inf: -550.5337 | lim_sup: 917.5562 Límites IQR -> lim_inf: 1128.7200 | lim_sup: 1590.7200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1344.7813 | lim_sup: 5110.3888 Límites IQR -> lim_inf: -537.7875 | lim_sup: 896.3125 Límites IQR -> lim_inf: -767.5800 | lim_sup: 1279.3000 Límites IQR -> lim_inf: -318.0375 | lim_sup: 530.0625 Límites IQR -> lim_inf: 1428.1150 | lim_sup: 2012.6350 Límites IQR -> lim_inf: -39.9000 | lim_sup: 66.5000 Límites IQR -> lim_inf: -1561.3338 | lim_sup: 6483.5763 Límites IQR -> lim_inf: -151.5000 | lim_sup: 252.5000 Límites IQR -> lim_inf: -1250.8200 | lim_sup: 2084.7000 Límites IQR -> lim_inf: -981.1550 | lim_sup: 1859.7050 Límites IQR -> lim_inf: 749.3550 | lim_sup: 5917.6350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2851.6837 | lim_sup: 9269.4862 Límites IQR -> lim_inf: -54.1687 | lim_sup: 90.2812 Límites IQR -> lim_inf: -1140.0600 | lim_sup: 1900.1000 Límites IQR -> lim_inf: -462.3525 | lim_sup: 1376.0675 Límites IQR -> lim_inf: 775.2200 | lim_sup: 5114.2600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -4367.9950 | lim_sup: 11207.0450 Límites IQR -> lim_inf: -817.1625 | lim_sup: 1361.9375 Límites IQR -> lim_inf: -720.0000 | lim_sup: 1200.0000 Límites IQR -> lim_inf: -442.0500 | lim_sup: 747.5100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -766.5525 | lim_sup: 1277.5875 Límites IQR -> lim_inf: 79.8000 | lim_sup: 407.0800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -253.0250 | lim_sup: 3455.1750 Límites IQR -> lim_inf: -1038.3250 | lim_sup: 3622.1150 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 177.0000 | lim_sup: 273.0000 Límites IQR -> lim_inf: -731.6250 | lim_sup: 1219.3750 Límites IQR -> lim_inf: -9415.5788 | lim_sup: 47745.1113 Límites IQR -> lim_inf: -1258.1175 | lim_sup: 2096.8625 Límites IQR -> lim_inf: -1207.0850 | lim_sup: 3717.3550 Límites IQR -> lim_inf: -339.6100 | lim_sup: 932.2300 Límites IQR -> lim_inf: 180.0000 | lim_sup: 180.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -392.8575 | lim_sup: 654.7625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1009.8200 | lim_sup: 3879.5400 Límites IQR -> lim_inf: 30.0000 | lim_sup: 30.0000 Límites IQR -> lim_inf: -154.4925 | lim_sup: 257.4875 Límites IQR -> lim_inf: -387.6212 | lim_sup: 1770.7887 Límites IQR -> lim_inf: -88.1250 | lim_sup: 146.8750 Límites IQR -> lim_inf: -243.4200 | lim_sup: 405.7000 Límites IQR -> lim_inf: -348.0950 | lim_sup: 1723.2050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -351.1837 | lim_sup: 671.6063 Límites IQR -> lim_inf: -112.0000 | lim_sup: 272.0000 Límites IQR -> lim_inf: -117.5550 | lim_sup: 195.9250 Límites IQR -> lim_inf: 20.1591 | lim_sup: 113.2864 Límites IQR -> lim_inf: 31.8182 | lim_sup: 68.1818 Límites IQR -> lim_inf: -1894.9864 | lim_sup: 4263.3591 Límites IQR -> lim_inf: 4731.7000 | lim_sup: 4942.6091 Límites IQR -> lim_inf: -201.1364 | lim_sup: 335.2273 Límites IQR -> lim_inf: 3994.5727 | lim_sup: 11748.4091 Límites IQR -> lim_inf: -95.6250 | lim_sup: 159.3750 Límites IQR -> lim_inf: -278.3375 | lim_sup: 1060.0025 Límites IQR -> lim_inf: -727.4625 | lim_sup: 1212.4375 Límites IQR -> lim_inf: -1203.9637 | lim_sup: 2006.6062 Límites IQR -> lim_inf: 1673.9550 | lim_sup: 3540.3150 Límites IQR -> lim_inf: -22.5000 | lim_sup: 37.5000 Límites IQR -> lim_inf: -1759.5525 | lim_sup: 6617.2275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -19.5375 | lim_sup: 32.5625 Límites IQR -> lim_inf: -731.2050 | lim_sup: 1218.6750 Límites IQR -> lim_inf: -739.4250 | lim_sup: 1232.3750 Límites IQR -> lim_inf: 254.3400 | lim_sup: 1053.1400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -247.1025 | lim_sup: 1699.6775 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -294.6175 | lim_sup: 1063.3625 Límites IQR -> lim_inf: 49.5979 | lim_sup: 2298.3334 Límites IQR -> lim_inf: 806.1457 | lim_sup: 4406.9862 Límites IQR -> lim_inf: -839.1525 | lim_sup: 1943.4915 Límites IQR -> lim_inf: 3198.3758 | lim_sup: 11638.6387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 245.1885 | lim_sup: 342.6125 Límites IQR -> lim_inf: -394.3286 | lim_sup: 2369.5069 Límites IQR -> lim_inf: 28.1225 | lim_sup: 8729.0545 Límites IQR -> lim_inf: -446.6644 | lim_sup: 1506.3446 Límites IQR -> lim_inf: 1560.2642 | lim_sup: 13782.2897 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -243.6288 | lim_sup: 978.3813 Límites IQR -> lim_inf: -769.7693 | lim_sup: 2202.3112 Límites IQR -> lim_inf: 968.0995 | lim_sup: 5928.3675 Límites IQR -> lim_inf: -113.4454 | lim_sup: 1326.0541 Límites IQR -> lim_inf: 6952.6608 | lim_sup: 21268.0068 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 191.5165 | lim_sup: 191.5165 Límites IQR -> lim_inf: -1655.7702 | lim_sup: 4049.4837 Límites IQR -> lim_inf: -5340.0633 | lim_sup: 9292.5322 Límites IQR -> lim_inf: -837.1111 | lim_sup: 1589.8519 Límites IQR -> lim_inf: 6282.4243 | lim_sup: 12222.8558 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -259.5067 | lim_sup: 1291.0673 Límites IQR -> lim_inf: -1085.5642 | lim_sup: 5107.2143 Límites IQR -> lim_inf: 2817.1608 | lim_sup: 8787.6133 Límites IQR -> lim_inf: -630.5403 | lim_sup: 2703.5802 Límites IQR -> lim_inf: 4748.7657 | lim_sup: 27103.5512 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 67.6988 | lim_sup: 1145.0648 Límites IQR -> lim_inf: -1317.1004 | lim_sup: 3455.9006 Límites IQR -> lim_inf: 2325.0285 | lim_sup: 6969.1445 Límites IQR -> lim_inf: -882.9803 | lim_sup: 2069.3672 Límites IQR -> lim_inf: 750.5172 | lim_sup: 28192.7587 Límites IQR -> lim_inf: 3541.9845 | lim_sup: 3541.9845 Límites IQR -> lim_inf: -273.7500 | lim_sup: 456.2500 Límites IQR -> lim_inf: 1339.4060 | lim_sup: 2542.1949 Límites IQR -> lim_inf: -1306.3912 | lim_sup: 5928.1453 Límites IQR -> lim_inf: -1063.0008 | lim_sup: 12333.2293 Límites IQR -> lim_inf: -2059.1860 | lim_sup: 4511.1440 Límites IQR -> lim_inf: -24788.1357 | lim_sup: 50125.0538 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -270.0000 | lim_sup: 450.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -39.9925 | lim_sup: 1496.5475 Límites IQR -> lim_inf: -163.4850 | lim_sup: 272.4750 Límites IQR -> lim_inf: 24.4300 | lim_sup: 49.1500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -67.4525 | lim_sup: 203.8075 Límites IQR -> lim_inf: -227.7650 | lim_sup: 349.7350 Límites IQR -> lim_inf: -10.1250 | lim_sup: 16.8750 Límites IQR -> lim_inf: 2842.9500 | lim_sup: 2842.9500 Límites IQR -> lim_inf: 20.0750 | lim_sup: 29.7950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -50.4950 | lim_sup: 327.1050 Límites IQR -> lim_inf: -202.5000 | lim_sup: 337.5000 Límites IQR -> lim_inf: 155.6300 | lim_sup: 155.6300 Límites IQR -> lim_inf: 309.5700 | lim_sup: 1716.7700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 117.4000 | lim_sup: 117.4000 Límites IQR -> lim_inf: -844.4962 | lim_sup: 1407.4937 Límites IQR -> lim_inf: -974.7413 | lim_sup: 1981.6888 Límites IQR -> lim_inf: -4427.5950 | lim_sup: 10416.7850 Límites IQR -> lim_inf: 1875.2250 | lim_sup: 2657.2650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -419.7350 | lim_sup: 2474.2450 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -869.1488 | lim_sup: 1448.5813 Límites IQR -> lim_inf: -96.0000 | lim_sup: 160.0000 Límites IQR -> lim_inf: -579.4000 | lim_sup: 4899.8000 Límites IQR -> lim_inf: -629.2975 | lim_sup: 2906.8225 Límites IQR -> lim_inf: -0.9350 | lim_sup: 49.2050 Límites IQR -> lim_inf: 276.9231 | lim_sup: 276.9231 Límites IQR -> lim_inf: -1194.7328 | lim_sup: 1991.2213 Límites IQR -> lim_inf: 136.4850 | lim_sup: 235.5850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -594.3262 | lim_sup: 990.5437 Límites IQR -> lim_inf: -149.9000 | lim_sup: 785.7000 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: 61.4818 | lim_sup: 233.6273 Límites IQR -> lim_inf: -2347.5500 | lim_sup: 4128.3409 Límites IQR -> lim_inf: -1012.3636 | lim_sup: 2353.6000 Límites IQR -> lim_inf: 4093.9023 | lim_sup: 5677.7750 Límites IQR -> lim_inf: -774.9955 | lim_sup: 1291.6591 Límites IQR -> lim_inf: 8030.4977 | lim_sup: 14582.1523 Límites IQR -> lim_inf: 80.9091 | lim_sup: 80.9091 Límites IQR -> lim_inf: 645.9909 | lim_sup: 645.9909 Límites IQR -> lim_inf: 3551.1545 | lim_sup: 3551.1545 Límites IQR -> lim_inf: 2336.5273 | lim_sup: 2336.5273 Límites IQR -> lim_inf: 54.5455 | lim_sup: 54.5455 Límites IQR -> lim_inf: -1035.2614 | lim_sup: 2257.0477 Límites IQR -> lim_inf: 4607.5182 | lim_sup: 5062.0636 Límites IQR -> lim_inf: 40.4773 | lim_sup: 566.1136 Límites IQR -> lim_inf: 3242.8591 | lim_sup: 23211.7682 Límites IQR -> lim_inf: -103.5675 | lim_sup: 505.0325 Límites IQR -> lim_inf: -322.4608 | lim_sup: 919.0714 Límites IQR -> lim_inf: -31.8250 | lim_sup: 115.3350 Límites IQR -> lim_inf: 314.9256 | lim_sup: 7002.8304 Límites IQR -> lim_inf: -1226.8387 | lim_sup: 2781.5289 Límites IQR -> lim_inf: -105.4750 | lim_sup: 240.3250 Límites IQR -> lim_inf: 770.0666 | lim_sup: 2905.6368 Límites IQR -> lim_inf: 89.5664 | lim_sup: 122.1360 Límites IQR -> lim_inf: -989.5391 | lim_sup: 2677.7303 Límites IQR -> lim_inf: -104.7200 | lim_sup: 252.5600 Límites IQR -> lim_inf: -108.4932 | lim_sup: 6666.5634 Límites IQR -> lim_inf: -922.0667 | lim_sup: 2402.3205 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: 205.1568 | lim_sup: 3991.8556 Límites IQR -> lim_inf: -1314.5285 | lim_sup: 4530.3547 Límites IQR -> lim_inf: -271.7750 | lim_sup: 511.6250 Límites IQR -> lim_inf: 2447.0511 | lim_sup: 9790.5464 Límites IQR -> lim_inf: -371.0077 | lim_sup: 1013.7315 Límites IQR -> lim_inf: -38.0200 | lim_sup: 116.7000 Límites IQR -> lim_inf: 180.8981 | lim_sup: 4803.1481 Límites IQR -> lim_inf: 61.4818 | lim_sup: 233.6273 Límites IQR -> lim_inf: -159.8045 | lim_sup: 266.3409 Límites IQR -> lim_inf: -392.5466 | lim_sup: 1139.8443 Límites IQR -> lim_inf: -1101.9091 | lim_sup: 9059.1091 Límites IQR -> lim_inf: -483.5045 | lim_sup: 805.8409 Límites IQR -> lim_inf: 5753.7636 | lim_sup: 9234.5636 Límites IQR -> lim_inf: 128.8909 | lim_sup: 551.6545 Límites IQR -> lim_inf: -1313.3455 | lim_sup: 2325.2727 Límites IQR -> lim_inf: -2858.1977 | lim_sup: 6160.4750 Límites IQR -> lim_inf: -4779.6750 | lim_sup: 13595.0341 Límites IQR -> lim_inf: -347.7273 | lim_sup: 579.5455 Límites IQR -> lim_inf: 14611.0420 | lim_sup: 19798.5784 Límites IQR -> lim_inf: -96.0858 | lim_sup: 160.1430 Límites IQR -> lim_inf: -123.6117 | lim_sup: 206.0194 Límites IQR -> lim_inf: -7.8254 | lim_sup: 423.1962 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -798.0000 | lim_sup: 1330.0000 Límites IQR -> lim_inf: 381.5000 | lim_sup: 617.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -73.7663 | lim_sup: 476.1438 Límites IQR -> lim_inf: -2504.1400 | lim_sup: 6870.9000 Límites IQR -> lim_inf: -837.7000 | lim_sup: 2293.9800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 299.0200 | lim_sup: 299.0200 Límites IQR -> lim_inf: -1144.6063 | lim_sup: 2436.7238 Límites IQR -> lim_inf: 144.2250 | lim_sup: 526.2650 Límites IQR -> lim_inf: -191.7300 | lim_sup: 319.5500 Límites IQR -> lim_inf: -1412.8550 | lim_sup: 10568.3850 Límites IQR -> lim_inf: 472.7762 | lim_sup: 1045.4462 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 144.9500 | lim_sup: 144.9500 Límites IQR -> lim_inf: -456.6112 | lim_sup: 761.0187 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 26.8175 | lim_sup: 104.6375 Límites IQR -> lim_inf: 80.9091 | lim_sup: 80.9091 Límites IQR -> lim_inf: -1200.2375 | lim_sup: 2835.4261 Límites IQR -> lim_inf: 4087.6364 | lim_sup: 4451.2727 Límites IQR -> lim_inf: -229.8409 | lim_sup: 1020.8864 Límites IQR -> lim_inf: -761.2000 | lim_sup: 29313.3455 Límites IQR -> lim_inf: 64.0900 | lim_sup: 94.2500 Límites IQR -> lim_inf: -226.9590 | lim_sup: 1750.9450 Límites IQR -> lim_inf: -47.9500 | lim_sup: 133.2500 Límites IQR -> lim_inf: 3575.2589 | lim_sup: 8589.7510 Límites IQR -> lim_inf: 103.5100 | lim_sup: 389.1100 Límites IQR -> lim_inf: 136.3200 | lim_sup: 465.2800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -182.9775 | lim_sup: 304.9625 Límites IQR -> lim_inf: -338.0625 | lim_sup: 673.0375 Límites IQR -> lim_inf: 64.0000 | lim_sup: 64.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 44.3275 | lim_sup: 2751.4275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -270.7200 | lim_sup: 451.2000 Límites IQR -> lim_inf: -569.6213 | lim_sup: 949.3688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 269.0000 | lim_sup: 269.0000 Límites IQR -> lim_inf: -120.2925 | lim_sup: 200.4875 Límites IQR -> lim_inf: -412.5000 | lim_sup: 687.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -137.2500 | lim_sup: 228.7500 Límites IQR -> lim_inf: -230.9625 | lim_sup: 432.9375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -901.5000 | lim_sup: 1502.5000 Límites IQR -> lim_inf: 4551.4000 | lim_sup: 4551.4000 Límites IQR -> lim_inf: 368.0100 | lim_sup: 540.7300 Límites IQR -> lim_inf: -1048.7250 | lim_sup: 3278.8750 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -819.1950 | lim_sup: 1365.3250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -117.7500 | lim_sup: 196.2500 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -522.9900 | lim_sup: 871.6500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 284.0000 | lim_sup: 284.0000 Límites IQR -> lim_inf: -92.2500 | lim_sup: 153.7500 Límites IQR -> lim_inf: -339.5938 | lim_sup: 619.7563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -33.0000 | lim_sup: 55.0000 Límites IQR -> lim_inf: -492.5000 | lim_sup: 907.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -1613.9350 | lim_sup: 6726.6650 Límites IQR -> lim_inf: 461.2455 | lim_sup: 461.2455 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -434.6591 | lim_sup: 724.4318 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 203.4000 | lim_sup: 203.4000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2517.3375 | lim_sup: 8093.0825 Límites IQR -> lim_inf: -777.0200 | lim_sup: 3817.2200 Límites IQR -> lim_inf: -538.5825 | lim_sup: 897.6375 Límites IQR -> lim_inf: -116.3475 | lim_sup: 193.9125 Límites IQR -> lim_inf: -37.5250 | lim_sup: 228.1950 Límites IQR -> lim_inf: -1172.5000 | lim_sup: 2347.5000 Límites IQR -> lim_inf: 172.0625 | lim_sup: 423.3625 Límites IQR -> lim_inf: 512.0300 | lim_sup: 1227.2300 Límites IQR -> lim_inf: -1016.1000 | lim_sup: 3300.0600 Límites IQR -> lim_inf: 1602.4800 | lim_sup: 1602.4800 Límites IQR -> lim_inf: 231.3231 | lim_sup: 4555.9951 Límites IQR -> lim_inf: 7911.7000 | lim_sup: 7911.7000 Límites IQR -> lim_inf: 45.6697 | lim_sup: 4819.3982 Límites IQR -> lim_inf: 9232.2632 | lim_sup: 15899.6192 Límites IQR -> lim_inf: 4382.6700 | lim_sup: 4382.6700 Límites IQR -> lim_inf: 146.3375 | lim_sup: 184.4775 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 8360.7770 | lim_sup: 8360.7770 Límites IQR -> lim_inf: 310.5100 | lim_sup: 310.5100
Visualizamos el resultado
print(df_train_enr.shape)
print(df_train_enr.head())
(65466, 9) ID_BUILDING FM_COST_TYPE FECHA YEAR cost_float_mod \ 0 2 Eficiencia Energética 2021-12-01 2021 0.00 1 2 Licencias 2021-01-01 2021 1145.46 2 2 Licencias 2021-02-01 2021 55.95 3 2 Licencias 2021-03-01 2021 0.00 4 2 Licencias 2021-04-01 2021 0.00 is_outlier cost_float_mod_2 cost_float_mean_y cost_float_mod_3 0 0 0.000000 NaN 0.0000 1 1 255.576875 219.4425 219.4425 2 0 241.589375 219.4425 55.9500 3 0 185.639375 219.4425 0.0000 4 0 185.639375 219.4425 0.0000
Analisis cambios bruscos de performance entre conjunto train y conjunto test.¶
Si existe cambio estructural entre los conjuntos de entrenamiento y test, hacer predicciones es inutil. Necesitamos profundizar en el analisis del conjunto de train vs. el de test una vez hemos tratado los outliers positivo y negativos.
Vamos a definir una estrategia práctica de detección automática de cambios estructurales (por ejemplo, con reglas de control estadístico o tests de cambio de nivel), para marcar qué series deben descartarse de la predicción estándar y tratarse con lógica de negocio
Lo enfocaremos así: antes de elegir cualquier pareja para modelizar, vamos a aplicar un filtro de estabilidad que nos permita clasificar las series en dos grupos:
Aptas para predicción estadística: mantienen un comportamiento más o menos estable entre 2021–2023 y 2024 (nivel, tendencia, estacionalidad).
Cambio estructural: presentan un salto brusco de nivel o patrón en 2024, lo que hace que el histórico pierda valor predictivo.
Estrategia de detección de cambios estructurales
Podemos aplicar varios indicadores sencillos y automáticos:
Comparación de medias:
Calculamos la media mensual 2021–2023 vs. la media mensual 2024.
Si el cociente supera un umbral (ej. ±30%), marcamos cambio estructural.
Comparación de desviación estándar:
Si la variabilidad cambia mucho (ej. más del doble), también es indicio de cambio.
Test de diferencia de medias (t-test o Mann-Whitney):
Evaluamos si las distribuciones de costes de 2021–2023 y 2024 son significativamente distintas.
Control por MAPE respecto a naïve:
Ajustamos un modelo naïve sobre 2021–2023 y calculamos el error en 2024.
Si el error es desproporcionado (ej. WAPE > 50%), clasificamos la serie como “no predecible”.
Flujo de trabajo
Para cada pareja (ID_BUILDING, FM_COST_TYPE):
Agrupamos por mes, sumamos contratos.
Calculamos los indicadores anteriores.
Clasificamos:
“Estable” si cumple criterios de estabilidad.
“Cambio estructural” si rompe alguno de los umbrales.
Obtenemos el porcentaje de series estables respecto al total.
Definimos función de estabilidad de datos entre entrenamiento y test con el criterio estricto definido.¶
# =====================================================
# 1) Clasificación de estabilidad (test siempre usa cost_float_mod)
# =====================================================
def evaluar_estabilidad(s_train, s_test, umbral_media=0.3, umbral_wape=0.5, umbral_std=2.0):
mean_train, mean_test = s_train.mean(), s_test.mean()
std_train, std_test = s_train.std(ddof=0), s_test.std(ddof=0)
ratio_media = np.inf if mean_train == 0 else abs(mean_test - mean_train) / abs(mean_train)
ratio_std = np.inf if std_train == 0 else std_test / std_train
if len(s_train.unique()) > 1 and len(s_test.unique()) > 1:
_, p_value = mannwhitneyu(s_train, s_test, alternative="two-sided")
else:
p_value = 1.0
wape = np.sum(np.abs(s_test - s_train.iloc[-1])) / np.sum(np.abs(s_test)) if s_test.sum() != 0 else np.inf
if (ratio_media > umbral_media) or (wape > umbral_wape) or (p_value < 0.05) or (ratio_std > umbral_std) or (ratio_std < 1/umbral_std):
clasificacion = "CAMBIO"
else:
clasificacion = "ESTABLE"
return ratio_media, ratio_std, p_value, wape, clasificacion
resultados = []
vars_coste = ["cost_float_mod", "cost_float_mod_2", "cost_float_mod_3"]
for (bid, ctype), g_train in df_train_enr.groupby(["ID_BUILDING","FM_COST_TYPE"]):
g_test = df_fd1_v5_ITE1_test[(df_fd1_v5_ITE1_test["ID_BUILDING"]==bid) & (df_fd1_v5_ITE1_test["FM_COST_TYPE"]==ctype)]
if g_test.empty:
continue
s_test = g_test.set_index("FECHA")["cost_float_mod"] # SIEMPRE real 2024 sin transformar
for var in vars_coste:
s_train = g_train.set_index("FECHA")[var]
# Aseguramos alineación mensual (por si faltaran meses en test)
s_train = s_train.sort_index()
s_test = s_test.sort_index()
ratio_media, ratio_std, p_value, wape, clasificacion = evaluar_estabilidad(s_train, s_test)
resultados.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"variable": var,
"ratio_media": ratio_media,
"ratio_std": ratio_std,
"p_value": p_value,
"WAPE": wape,
"clasificacion": clasificacion
})
df_estabilidad = pd.DataFrame(resultados)
# =====================================================
# 2) Resumen porcentual por variable
# =====================================================
resumen = (
df_estabilidad.groupby(["variable","clasificacion"])
.size().groupby(level=0).apply(lambda x: 100*x/x.sum())
.unstack(fill_value=0)
).sort_index()
print("=== RESUMEN % ESTABLES POR VARIABLE (test usa cost_float_mod real) ===")
print(resumen)
print("\n=== MUESTRA DEL DETALLE ===")
print(df_estabilidad.head())def evaluar_estabilidad(s_train, s_test, umbral_media=0.3, umbral_wape=0.5, umbral_std=2.0):
# Medias y desviaciones
mean_train, mean_test = s_train.mean(), s_test.mean()
std_train, std_test = s_train.std(ddof=0), s_test.std(ddof=0)
# Ratio de medias
ratio_media = np.inf if mean_train == 0 else abs(mean_test - mean_train) / abs(mean_train)
# Ratio de desviaciones
ratio_std = np.inf if std_train == 0 else std_test / std_train
# Test de distribuciones (si hay varianza en ambas partes)
if len(s_train.unique()) > 1 and len(s_test.unique()) > 1:
stat, p_value = mannwhitneyu(s_train, s_test, alternative="two-sided")
else:
p_value = 1.0
# WAPE naïve: predicción = último valor de train
naive_pred = pd.Series([s_train.iloc[-1]] * len(s_test), index=s_test.index)
wape = np.sum(np.abs(s_test - naive_pred)) / np.sum(np.abs(s_test)) if s_test.sum() != 0 else np.inf
# Clasificación
if (ratio_media > umbral_media) or (wape > umbral_wape) or (p_value < 0.05) or (ratio_std > umbral_std) or (ratio_std < 1/umbral_std):
clasificacion = "CAMBIO"
else:
clasificacion = "ESTABLE"
return {
"ratio_media": ratio_media,
"ratio_std": ratio_std,
"p_value": p_value,
"WAPE": wape,
"clasificacion": clasificacion
}
# Ejemplo aplicado a UNA serie (mod)
resultado = evaluar_estabilidad(
serie_train_enriquecida.set_index("FECHA")["cost_float_mod"],
s_real.set_index("FECHA")["valor"]
)
print(resultado)
{'ratio_media': np.float64(0.6075727799407163), 'ratio_std': 0.330877232154499, 'p_value': np.float64(0.03942269395503449), 'WAPE': np.float64(2.996209566379454), 'clasificacion': 'CAMBIO'}
Mejoramos la función evaluar estabilidad para que incorpore el resultado de aplicar los clasificaciones de cada metrica y los guardamos.
Procedemos a definir la función mejorada y clasificamos todas las series del dataframe train¶
# Nosotros usamos esta evaluación desglosada
def evaluar_estabilidad_desglosado(
s_train: pd.Series,
s_test: pd.Series,
umbral_media: float = 0.3,
umbral_wape: float = 0.5,
umbral_std: float = 2.0,
alpha_pvalue: float = 0.05,
):
# Aseguramos tipos y orden
s_train = s_train.astype(float).sort_index()
s_test = s_test.astype(float).sort_index()
# Métricas base
mean_train, mean_test = s_train.mean(), s_test.mean()
std_train, std_test = s_train.std(ddof=0), s_test.std(ddof=0)
ratio_media = np.inf if mean_train == 0 else abs(mean_test - mean_train) / abs(mean_train)
ratio_std = np.inf if std_train == 0 else (std_test / std_train)
if s_train.nunique() > 1 and s_test.nunique() > 1:
_, p_value = mannwhitneyu(s_train.values, s_test.values, alternative="two-sided")
else:
p_value = 1.0 # sin variabilidad suficiente, no señalamos diferencia
if s_test.abs().sum() == 0:
wape = np.inf
else:
naive_pred = pd.Series([s_train.iloc[-1]] * len(s_test), index=s_test.index)
wape = (s_test - naive_pred).abs().sum() / s_test.abs().sum()
# Reglas por métrica (flags)
flag_media = ratio_media > umbral_media
flag_ratio_std = (ratio_std > umbral_std) or (ratio_std < 1.0/umbral_std)
flag_pvalue = p_value < alpha_pvalue
flag_wape = wape > umbral_wape
# Clasificaciones por métrica
clasificacion_media = "CAMBIO" if flag_media else "ESTABLE"
clasificacion_ratio_std = "CAMBIO" if flag_ratio_std else "ESTABLE"
clasificacion_pvalue = "CAMBIO" if flag_pvalue else "ESTABLE"
clasificacion_wape = "CAMBIO" if flag_wape else "ESTABLE"
# Clasificación global (OR de las reglas)
flag_global = flag_media or flag_ratio_std or flag_pvalue or flag_wape
clasificacion_global = "CAMBIO" if flag_global else "ESTABLE"
return {
# Métricas numéricas
"ratio_media": ratio_media,
"ratio_std": ratio_std,
"p_value": p_value,
"WAPE": wape,
# Flags (0/1) por si queremos auditar fácil
"flag_media": int(flag_media),
"flag_ratio_std": int(flag_ratio_std),
"flag_pvalue": int(flag_pvalue),
"flag_wape": int(flag_wape),
"flag_global": int(flag_global),
# Clasificaciones por métrica
"clasificacion_media": clasificacion_media,
"clasificacion_ratio_std": clasificacion_ratio_std,
"clasificacion_pvalue": clasificacion_pvalue,
"clasificacion_wape": clasificacion_wape,
# Clasificación global
"clasificacion_estad_global_train_vs_test": clasificacion_global,
}
# Ejemplo de integración dentro del bucle que construye df_estabilidad
# (asumimos que ya tenemos df_train_enr y df_fd1_v5_ITE1_test, y la función _to_monthly_series definida)
def _to_monthly_series(g: pd.DataFrame, col: str):
s = g.set_index("FECHA")[col].sort_index()
return s.resample("MS").sum().fillna(0.0)
resultados = []
vars_coste = ["cost_float_mod", "cost_float_mod_2", "cost_float_mod_3"]
pairs_train = df_train_enr[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_test = df_fd1_v5_ITE1_test[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_both = pairs_train.merge(pairs_test, on=["ID_BUILDING","FM_COST_TYPE"])
for _, row in pairs_both.iterrows():
bid, ctype = row["ID_BUILDING"], row["FM_COST_TYPE"]
g_train = df_train_enr[(df_train_enr["ID_BUILDING"]==bid) & (df_train_enr["FM_COST_TYPE"]==ctype)]
g_test = df_fd1_v5_ITE1_test[(df_fd1_v5_ITE1_test["ID_BUILDING"]==bid) & (df_fd1_v5_ITE1_test["FM_COST_TYPE"]==ctype)]
if g_train.empty or g_test.empty:
continue
# Serie real 2024 sin transformar
s_test = _to_monthly_series(g_test, "cost_float_mod")
s_test = s_test[(s_test.index.year == 2024)]
for var in vars_coste:
s_train = _to_monthly_series(g_train, var)
s_train = s_train[(s_train.index.year >= 2021) & (s_train.index.year <= 2023)]
if len(s_train)==0 or len(s_test)==0:
continue
res = evaluar_estabilidad_desglosado(s_train, s_test)
resultados.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"variable": var,
**res # expandimos el diccionario con métricas, flags y clasificaciones
})
df_estabilidad = pd.DataFrame(resultados)
# Resumen: % ESTABLE/CAMBIO usando la clasificación global
resumen_global = (
df_estabilidad.groupby(["variable","clasificacion_estad_global_train_vs_test"])
.size()
.groupby(level=0)
.apply(lambda x: 100*x/x.sum())
.unstack(fill_value=0)
).round(2)
print("=== RESUMEN % (clasificación global) ===")
print(resumen_global)
print("\n=== Muestra del detalle con desgloses ===")
print(df_estabilidad.head())
=== RESUMEN % (clasificación global) === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable variable cost_float_mod cost_float_mod 97.40 2.60 cost_float_mod_2 cost_float_mod_2 96.95 3.05 cost_float_mod_3 cost_float_mod_3 97.40 2.60 === Muestra del detalle con desgloses === ID_BUILDING FM_COST_TYPE variable ratio_media ratio_std \ 0 2 Licencias cost_float_mod 0.622975 0.000000 1 2 Licencias cost_float_mod_2 0.622975 0.000000 2 2 Licencias cost_float_mod_3 3.469217 0.000000 3 2 Mtto. Contratos cost_float_mod 0.750333 0.614012 4 2 Mtto. Contratos cost_float_mod_2 0.750333 0.653764 p_value WAPE flag_media flag_ratio_std flag_pvalue flag_wape \ 0 1.00000 1.000000 1 1 0 1 1 1.00000 1.000000 1 1 0 1 2 1.00000 1.000000 1 1 0 1 3 0.00138 3.078200 1 0 1 1 4 0.00138 3.226715 1 0 1 1 flag_global clasificacion_media clasificacion_ratio_std \ 0 1 CAMBIO CAMBIO 1 1 CAMBIO CAMBIO 2 1 CAMBIO CAMBIO 3 1 CAMBIO ESTABLE 4 1 CAMBIO ESTABLE clasificacion_pvalue clasificacion_wape \ 0 ESTABLE CAMBIO 1 ESTABLE CAMBIO 2 ESTABLE CAMBIO 3 CAMBIO CAMBIO 4 CAMBIO CAMBIO clasificacion_estad_global_train_vs_test 0 CAMBIO 1 CAMBIO 2 CAMBIO 3 CAMBIO 4 CAMBIO
Interpretación¶
El resultado es horrible, con este criterio tan rígido, no permitimos que ninguna serie pueda tener continuidad estructural entre la serie histórica y el año 2024.
Vamos a proponer un criterio más robusto de clasificación de la continuidad estructural.
Definimos las funciones de nuevo con un criterio de mayor robustez.¶
# 1) Auxiliares
def _to_ms(g, col):
s = g.set_index("FECHA")[col].sort_index()
return s.resample("MS").sum().fillna(0.0)
def _mad(x):
# MAD robusta (normalizada a sigma ~ 1.4826 * MAD si quisiéramos)
x = np.asarray(x, dtype=float)
return median_abs_deviation(x, scale=1.0, nan_policy="omit")
def _prop_zeros(x):
x = np.asarray(x, dtype=float)
return np.mean(x == 0.0)
# 2) Evaluación robusta por pareja y variable
def evaluar_estabilidad_robusta(
s_train, s_test,
alpha_pvalue=0.05,
umbral_mediana=0.30, # |mediana_24 - mediana_train| / |mediana_train|
umbral_mad_hi=1.75, # ratio MAD fuera [1/umbral_mad_hi, umbral_mad_hi]
umbral_wape=0.50, # WAPE vs naive anual
umbral_anual=0.30, # |suma_24 - media_anual_train| / |media_anual_train|
umbral_delta_zeros=0.30, # cambio en proporción de ceros > 30 p.p.
regla_mayoria_k=2 # nº mínimo de flags para CAMBIO
):
s_train = s_train.astype(float)
s_test = s_test.astype(float)
# Ventanas
train_yrs = s_train.index.year
y_max = train_yrs.max() if len(train_yrs) else 2023
s_train_last_year = s_train[train_yrs == y_max] # último año del train (2023)
# Si por alguna razón no hay 2023, usamos todo el train como fallback
if s_train_last_year.empty:
s_train_last_year = s_train
# Señales robustas
med_tr, med_te = np.median(s_train), np.median(s_test)
ratio_mediana = np.inf if med_tr == 0 else abs(med_te - med_tr) / abs(med_tr)
mad_tr, mad_te = _mad(s_train), _mad(s_test)
if mad_tr == 0:
ratio_mad = np.inf if mad_te > 0 else 1.0
else:
ratio_mad = mad_te / mad_tr
# Test de distribuciones (Mann-Whitney) si hay variabilidad
if s_train.nunique() > 1 and s_test.nunique() > 1:
_, p_value = mannwhitneyu(s_train.values, s_test.values, alternative="two-sided")
else:
p_value = 1.0
# WAPE vs naïve anual (predecimos todo 2024 con la mediana del último año de train)
naive_level = float(np.median(s_train_last_year))
denom = s_test.abs().sum()
if denom == 0:
wape = np.inf
else:
wape = (s_test - naive_level).abs().sum() / denom
# Chequeo anual (comparativa de masa)
# media_anual_train = promedio de sumas anuales en 2021-2023
train_by_year = s_train.groupby(s_train.index.year).sum()
if len(train_by_year) == 0 or train_by_year.mean() == 0:
ratio_anual = np.inf
else:
ratio_anual = abs(s_test.sum() - train_by_year.mean()) / abs(train_by_year.mean())
# Sparsidad: cambio en % de ceros
dz = abs(_prop_zeros(s_test) - _prop_zeros(s_train))
# Flags
flag_mediana = ratio_mediana > umbral_mediana
flag_mad = (ratio_mad > umbral_mad_hi) or (ratio_mad < 1.0/umbral_mad_hi)
flag_pvalue = p_value < alpha_pvalue
flag_wape = wape > umbral_wape
flag_anual = ratio_anual > umbral_anual
flag_sparsidad = dz > umbral_delta_zeros
flags = dict(
flag_mediana=int(flag_mediana),
flag_mad=int(flag_mad),
flag_pvalue=int(flag_pvalue),
flag_wape=int(flag_wape),
flag_anual=int(flag_anual),
flag_sparsidad=int(flag_sparsidad),
)
n_flags = sum(flags.values())
clasif_global = "CAMBIO" if n_flags >= regla_mayoria_k else "ESTABLE"
return {
# Métricas
"ratio_mediana": ratio_mediana,
"ratio_mad": ratio_mad,
"p_value": p_value,
"WAPE_naive_anual": wape,
"ratio_anual": ratio_anual,
"delta_prop_zeros": dz,
# Flags
**flags,
"n_flags": n_flags,
# Clasificación
"clasificacion_estad_global_train_vs_test": clasif_global
}
Aplicamos el nuevo de clasificación robusto al conjunto train enriquecido con las 3 variables de coste.¶
Aplicamos al panel (train enriquecido vs test real)
# Aplicamos al panel (train enriquecido vs test real)
vars_coste = ["cost_float_mod", "cost_float_mod_2", "cost_float_mod_3"]
resultados = []
pairs_train = df_train_enr[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_test = df_fd1_v5_ITE1_test[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_both = pairs_train.merge(pairs_test, on=["ID_BUILDING","FM_COST_TYPE"])
for _, row in pairs_both.iterrows():
bid, ctype = row["ID_BUILDING"], row["FM_COST_TYPE"]
g_train = df_train_enr[(df_train_enr["ID_BUILDING"]==bid) & (df_train_enr["FM_COST_TYPE"]==ctype)]
g_test = df_fd1_v5_ITE1_test[(df_fd1_v5_ITE1_test["ID_BUILDING"]==bid) & (df_fd1_v5_ITE1_test["FM_COST_TYPE"]==ctype)]
if g_train.empty or g_test.empty:
continue
s_test = _to_ms(g_test, "cost_float_mod")
s_test = s_test[(s_test.index.year == 2024)]
for var in vars_coste:
s_train = _to_ms(g_train, var)
s_train = s_train[(s_train.index.year >= 2021) & (s_train.index.year <= 2023)]
if len(s_train)==0 or len(s_test)==0:
continue
res = evaluar_estabilidad_robusta(s_train, s_test,
alpha_pvalue=0.05,
umbral_mediana=0.30,
umbral_mad_hi=1.75,
umbral_wape=0.50,
umbral_anual=0.30,
umbral_delta_zeros=0.30,
regla_mayoria_k=2) # aquí fijamos mayoría (2 señales)
resultados.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"variable": var,
**res
})
df_estabilidad_rb = pd.DataFrame(resultados)
# 4) Resumen por variable con la nueva clasificación global (mayoría)
resumen_rb = (
df_estabilidad_rb.groupby(["variable","clasificacion_estad_global_train_vs_test"])
.size()
.groupby(level=0)
.apply(lambda x: 100*x/x.sum())
.unstack(fill_value=0)
).round(2)
print("=== RESUMEN % (regla de mayoría, señales robustas) ===")
print(resumen_rb)
print("\n=== Muestra detalle ===")
print(df_estabilidad_rb.head())
=== RESUMEN % (regla de mayoría, señales robustas) === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable variable cost_float_mod cost_float_mod 84.76 15.24 cost_float_mod_2 cost_float_mod_2 86.02 13.98 cost_float_mod_3 cost_float_mod_3 84.89 15.11 === Muestra detalle === ID_BUILDING FM_COST_TYPE variable ratio_mediana ratio_mad \ 0 2 Licencias cost_float_mod inf 1.0 1 2 Licencias cost_float_mod_2 0.590248 0.0 2 2 Licencias cost_float_mod_3 inf 1.0 3 2 Mtto. Contratos cost_float_mod 0.830177 0.0 4 2 Mtto. Contratos cost_float_mod_2 0.838205 0.0 p_value WAPE_naive_anual ratio_anual delta_prop_zeros flag_mediana \ 0 1.00000 1.000000 0.860888 0.742857 1 1 1.00000 1.000000 0.860888 0.314286 1 2 1.00000 1.000000 0.616924 0.742857 1 3 0.00138 3.435270 0.750333 0.027778 1 4 0.00138 3.583785 0.750333 0.027778 1 flag_mad flag_pvalue flag_wape flag_anual flag_sparsidad n_flags \ 0 0 0 1 1 1 4 1 1 0 1 1 1 5 2 0 0 1 1 1 4 3 1 1 1 1 0 5 4 1 1 1 1 0 5 clasificacion_estad_global_train_vs_test 0 CAMBIO 1 CAMBIO 2 CAMBIO 3 CAMBIO 4 CAMBIO
Interpretación¶
El resultado sigue siendo horrible, aunque ha mejorado.
Aplicación de la clasificación de estabilidad de las series train vs. test solo a las del 2022-2023.¶
Ahora lo aplicamos solo con las series históricas del 2022 y 2023 del conjunto entrenamiento, donde el equipo FM nos ha dicho que existe continuidad en el modelo de gestión.
# =====================================================
# Clasificación de estabilidad usando sólo 2022–2023
# =====================================================
vars_coste = ["cost_float_mod", "cost_float_mod_2", "cost_float_mod_3"]
resultados = []
pairs_train = df_train_enr[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_test = df_fd1_v5_ITE1_test[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_both = pairs_train.merge(pairs_test, on=["ID_BUILDING","FM_COST_TYPE"])
for _, row in pairs_both.iterrows():
bid, ctype = row["ID_BUILDING"], row["FM_COST_TYPE"]
g_train = df_train_enr[
(df_train_enr["ID_BUILDING"]==bid) &
(df_train_enr["FM_COST_TYPE"]==ctype) &
(df_train_enr["YEAR"].between(2022, 2023))
]
g_test = df_fd1_v5_ITE1_test[
(df_fd1_v5_ITE1_test["ID_BUILDING"]==bid) &
(df_fd1_v5_ITE1_test["FM_COST_TYPE"]==ctype) &
(df_fd1_v5_ITE1_test["YEAR"]==2024)
]
if g_train.empty or g_test.empty:
continue
s_test = _to_ms(g_test, "cost_float_mod")
s_test = s_test[(s_test.index.year == 2024)]
for var in vars_coste:
s_train = _to_ms(g_train, var)
s_train = s_train[(s_train.index.year >= 2022) & (s_train.index.year <= 2023)]
if len(s_train)==0 or len(s_test)==0:
continue
res = evaluar_estabilidad_robusta(
s_train, s_test,
alpha_pvalue=0.05,
umbral_mediana=0.30,
umbral_mad_hi=1.75,
umbral_wape=0.50,
umbral_anual=0.30,
umbral_delta_zeros=0.30,
regla_mayoria_k=2
)
resultados.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"variable": var,
**res
})
df_estabilidad_rb_22_23 = pd.DataFrame(resultados)
# Resumen % por variable
resumen_rb_22_23 = (
df_estabilidad_rb_22_23.groupby(["variable","clasificacion_estad_global_train_vs_test"])
.size()
.groupby(level=0)
.apply(lambda x: 100*x/x.sum())
.unstack(fill_value=0)
).round(2)
print("=== RESUMEN % (2022–2023 vs 2024) ===")
print(resumen_rb_22_23)
print("\n=== Muestra detalle ===")
print(df_estabilidad_rb_22_23.head())
=== RESUMEN % (2022–2023 vs 2024) === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable variable cost_float_mod cost_float_mod 84.27 15.73 cost_float_mod_2 cost_float_mod_2 83.95 16.05 cost_float_mod_3 cost_float_mod_3 84.77 15.23 === Muestra detalle === ID_BUILDING FM_COST_TYPE variable ratio_mediana ratio_mad \ 0 2 Licencias cost_float_mod inf 1.0 1 2 Licencias cost_float_mod_2 0.590248 0.0 2 2 Licencias cost_float_mod_3 inf 1.0 3 2 Mtto. Contratos cost_float_mod 0.859309 0.0 4 2 Mtto. Contratos cost_float_mod_2 0.863133 0.0 p_value WAPE_naive_anual ratio_anual delta_prop_zeros flag_mediana \ 0 1.000000 1.000000 0.766915 0.869565 1 1 1.000000 1.000000 0.766915 0.478261 1 2 1.000000 1.000000 0.067661 0.869565 1 3 0.000028 3.435270 0.810539 0.166667 1 4 0.000028 3.583785 0.810539 0.166667 1 flag_mad flag_pvalue flag_wape flag_anual flag_sparsidad n_flags \ 0 0 0 1 1 1 4 1 1 0 1 1 1 5 2 0 0 1 0 1 3 3 1 1 1 1 0 5 4 1 1 1 1 0 5 clasificacion_estad_global_train_vs_test 0 CAMBIO 1 CAMBIO 2 CAMBIO 3 CAMBIO 4 CAMBIO
Interpretación¶
Se mejora el resultado pero no sustancialmente. Sigue siendo horrible para predicciones mediante series temporales.
Vamos a mirar ahora con las series de la iteración 2, ITE2, pero solo con datos reales hasta 31 de agosto 2025.
Cargamos test y train de ITE2 de detalle, sin agrupación mensual.¶
# Ruta base donde guardamos los outputs
ruta_base = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/"
# Dataset de entrenamiento (Iteración 2: 2022-2024)
df_fd1_v5_ITE2 = pd.read_csv(ruta_base + "df_fd1_v5_ITE2.csv", sep=";")
# Dataset de reales (Iteración 2: datos del 2025 hasta agosto)
df_fd1_v5_Real_ITE2 = pd.read_csv(ruta_base + "df_fd1_v5_Real_ITE2_2025_hasta_agosto.csv", sep=";")
# Confirmamos tamaños y columnas
print("ITE2 shape:", df_fd1_v5_ITE2.shape)
print("Real_ITE2 shape:", df_fd1_v5_Real_ITE2.shape)
print("\nColumnas ITE2:", df_fd1_v5_ITE2.columns.tolist())
print("Columnas Real_ITE2:", df_fd1_v5_Real_ITE2.columns.tolist())
ITE2 shape: (243916, 12) Real_ITE2 shape: (57932, 12) Columnas ITE2: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF'] Columnas Real_ITE2: ['ID_ORDER', 'ID_BUILDING', 'FM_COST_TYPE', 'MONTH', 'YEAR', 'cost_float_mod', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD', 'TIPO_USO', 'COUNTRY_CATALOGO', 'ID_REGION_GRUPO', 'COUNTRY_DEF']
Obtención del conjunto train y test para ITE2 con agrupaciones mensuales y conciliación de series respecto el conjunto test.¶
Ajustamos a _train y _test los dataframes de la ITE2 con reales hasta agosto 2025
# ===============================================================
# Secuencia ITE2) Generamos df_fd1_v5_ITE2_train y df_fd1_v5_ITE2_test
# Partimos de:
# - df_fd1_v5_ITE2 (detalle 2022–2024)
# - df_fd1_v5_Real_ITE2 (detalle 2025 hasta agosto)
# ===============================================================
# 1) Creamos FECHA en ambos orígenes
df_ite2_detalle_train = crear_fecha(df_fd1_v5_ITE2)
df_ite2_detalle_test = crear_fecha(df_fd1_v5_Real_ITE2)
# 2) Filtramos por YEAR explícitamente
df_ite2_detalle_train = df_ite2_detalle_train[(df_ite2_detalle_train["YEAR"]>=2022) & (df_ite2_detalle_train["YEAR"]<=2024)]
df_ite2_detalle_test = df_ite2_detalle_test[(df_ite2_detalle_test["YEAR"]==2025)]
# 3) Agregamos a nivel mensual por pareja (suma de cost_float_mod)
agg_train_ite2 = serie_mensual_con_huecos(df_ite2_detalle_train, 2022, 2024)
agg_test_ite2 = serie_mensual_con_huecos(df_ite2_detalle_test, 2025, 2025)
# 4) Rellenamos meses faltantes entre el primer y último mes observado de cada pareja
serie_train_completa_ite2 = completar_meses_por_pareja(agg_train_ite2)
serie_test_completa_ite2 = completar_meses_por_pareja(agg_test_ite2)
# 5) Calculamos contexto por pareja usando SOLO 2022–2024 (moda)
ctx_2022_2024 = contexto_por_pareja(df_ite2_detalle_train)
# 6) Ensamblamos finales con contexto y añadimos YEAR/MONTH
df_fd1_v5_ITE2_train = ensamblar_final(serie_train_completa_ite2, ctx_2022_2024)
df_fd1_v5_ITE2_test = ensamblar_final(serie_test_completa_ite2, ctx_2022_2024)
# 7) Comprobaciones rápidas
chequeo_rapido("df_fd1_v5_ITE2_train (2022–2024)", df_fd1_v5_ITE2_train, 2022, 2024)
chequeo_rapido("df_fd1_v5_ITE2_test (2025)", df_fd1_v5_ITE2_test, 2025, 2025)
=== df_fd1_v5_ITE2_train (2022–2024) === Shape: (72492, 11) FECHA min: 2022-01-01 00:00:00 | FECHA max: 2024-12-01 00:00:00 Duplicados por pareja/FECHA: 0 Muestra de filas: ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2022-02-01 2022 2 610.95 1 2 Licencias 2022-03-01 2022 3 927.09 2 2 Licencias 2022-04-01 2022 4 0.00 3 2 Licencias 2022-05-01 2022 5 0.00 4 2 Licencias 2022-06-01 2022 6 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD 0 España 2 Oficinas INTERNO Licencias 1 España 2 Oficinas INTERNO Licencias 2 España 2 Oficinas INTERNO Licencias 3 España 2 Oficinas INTERNO Licencias 4 España 2 Oficinas INTERNO Licencias ============================================================ === df_fd1_v5_ITE2_test (2025) === Shape: (14836, 11) FECHA min: 2025-01-01 00:00:00 | FECHA max: 2025-08-01 00:00:00 Duplicados por pareja/FECHA: 0 Muestra de filas: ID_BUILDING FM_COST_TYPE FECHA YEAR MONTH cost_float_mod \ 0 2 Licencias 2025-02-01 2025 2 20.00 1 2 Mtto. Contratos 2025-01-01 2025 1 1126.99 2 2 Mtto. Contratos 2025-02-01 2025 2 0.00 3 2 Mtto. Contratos 2025-03-01 2025 3 0.00 4 2 Mtto. Contratos 2025-04-01 2025 4 0.00 COUNTRY_DEF ID_REGION_GRUPO TIPO_USO SUPPLIER_TYPE_MOD_2 FM_RESPONSIBLE_MOD 0 España 2 Oficinas INTERNO Licencias 1 España 2 Oficinas EXTERNO Mantenimiento 2 España 2 Oficinas EXTERNO Mantenimiento 3 España 2 Oficinas EXTERNO Mantenimiento 4 España 2 Oficinas EXTERNO Mantenimiento ============================================================
Agrupación de funciones¶
Bloque agrupando todas las funciones definidas (ya estan repetidas en el notebook pero en bloques individuales). Es un bloque que no ejecutamos, aunque no debería modificar en nada los resultados si se ejecutara.
# =======================
# Funciones Auxiliares reutilizados en un bloque
# =======================
def _ensure_monthly(df, date_col="FECHA"):
df = df.copy()
df[date_col] = pd.to_datetime(df[date_col])
df = df.sort_values(date_col)
fmin, fmax = df[date_col].min(), df[date_col].max()
idx = pd.date_range(fmin, fmax, freq="MS")
df = df.set_index(date_col).reindex(idx)
df.index.name = date_col
df = df.reset_index()
return df
def _iqr_limits(x: pd.Series):
Q1 = x.quantile(0.25); Q3 = x.quantile(0.75); IQR = Q3 - Q1
return float(Q1 - 1.5*IQR), float(Q3 + 1.5*IQR)
def _redistribuir_deficit_sin_negativos(vals: np.ndarray, deficit: float) -> np.ndarray:
s = vals.astype(float).copy(); rem = float(deficit)
if rem <= 0: return s
while rem > 1e-12:
pos_mask = s > 0; total_pos = s[pos_mask].sum()
if total_pos <= 0: break
reduccion = np.zeros_like(s); reduccion[pos_mask] = rem * (s[pos_mask]/total_pos)
cap = s.copy(); exceso_cap = np.maximum(reduccion - cap, 0.0)
reduccion_efectiva = reduccion - exceso_cap
s = s - reduccion_efectiva; s[s < 0] = 0.0
rem = float(exceso_cap.sum());
if rem <= 1e-12: rem = 0.0; break
return s
def transformar_serie_outliers(df_serie, date_col="FECHA", value_col="cost_float_mod", asegurar_mensual=True):
st = df_serie[[date_col, value_col]].copy()
st[date_col] = pd.to_datetime(st[date_col]); st = st.sort_values(date_col)
if asegurar_mensual:
st = _ensure_monthly(st, date_col=date_col)
if value_col not in st.columns: st[value_col] = 0.0
st[value_col] = st[value_col].fillna(0.0)
st["YEAR"] = st[date_col].dt.year
st[value_col] = st[value_col].astype(float)
lim_inf, lim_sup = _iqr_limits(st[value_col])
st["is_outlier"] = ((st[value_col] < lim_inf) | (st[value_col] > lim_sup)).astype(int)
st["cost_float_mod"] = st[value_col]
st["cost_float_mod_2"] = st["cost_float_mod"].astype(float)
# Outliers positivos: aplanado + redistribución anual del exceso
for y in sorted(st["YEAR"].unique()):
m = st["YEAR"] == y
vals = st.loc[m, "cost_float_mod_2"].values
exceso = np.maximum(vals - lim_sup, 0.0); exceso_total = float(exceso.sum())
if exceso_total > 0:
st.loc[m, "cost_float_mod_2"] = np.minimum(vals, lim_sup)
st.loc[m, "cost_float_mod_2"] += exceso_total / int(m.sum())
# Outliers negativos: reglas
for y in sorted(st["YEAR"].unique()):
m = st["YEAR"] == y
vals_orig = st.loc[m, "cost_float_mod"].values
vals_adj = st.loc[m, "cost_float_mod_2"].values
lim_inf_y, _ = _iqr_limits(st["cost_float_mod"]) # lim_inf global de la serie
out_neg = vals_orig < lim_inf_y
if not np.any(out_neg):
continue
suma_anual = float(vals_orig.sum())
if suma_anual < 0:
vals_adj[out_neg] = 0.0
else:
deficit = float(np.abs(vals_adj[out_neg]).sum())
vals_adj[out_neg] = 0.0
if deficit > 0: vals_adj = _redistribuir_deficit_sin_negativos(vals_adj, deficit)
st.loc[m, "cost_float_mod_2"] = vals_adj
# cost_float_mean_y: media anual original en años con outliers
st["cost_float_mean_y"] = np.nan
years_out = st.groupby("YEAR")["is_outlier"].max()
years_out = years_out[years_out == 1].index.tolist()
if years_out:
media_anual = st.groupby("YEAR")["cost_float_mod"].mean()
for y in years_out:
st.loc[st["YEAR"] == y, "cost_float_mean_y"] = media_anual.loc[y]
# cost_float_mod_3: original con outliers sustituidos por la media anual
st["cost_float_mod_3"] = st["cost_float_mod"]
m_out = st["is_outlier"] == 1
st.loc[m_out, "cost_float_mod_3"] = st.loc[m_out, "cost_float_mean_y"]
return st[[date_col, "YEAR", "cost_float_mod","cost_float_mod_2","cost_float_mean_y","cost_float_mod_3"]].sort_values(date_col).reset_index(drop=True)
def transformar_panel_outliers(df_panel, id_cols=("ID_BUILDING","FM_COST_TYPE"), date_col="FECHA", value_col="cost_float_mod", asegurar_mensual=True):
cols_min = list(id_cols) + [date_col, value_col]
missing = set(cols_min) - set(df_panel.columns)
if missing: raise ValueError(f"Faltan columnas: {missing}")
df_panel = df_panel[cols_min].copy()
df_panel[date_col] = pd.to_datetime(df_panel[date_col])
outs = []
for keys, g in df_panel.groupby(list(id_cols), sort=False):
t = transformar_serie_outliers(g[[date_col, value_col]], date_col=date_col, value_col=value_col, asegurar_mensual=asegurar_mensual)
for i, col in enumerate(id_cols):
t[col] = keys[i] if isinstance(keys, tuple) else keys
outs.append(t)
out = pd.concat(outs, ignore_index=True)
out = out[list(id_cols) + [date_col, "YEAR", "cost_float_mod","cost_float_mod_2","cost_float_mean_y","cost_float_mod_3"]]
return out.sort_values(list(id_cols) + [date_col]).reset_index(drop=True)
def _to_ms(g, col):
s = g.set_index("FECHA")[col].sort_index()
return s.resample("MS").sum().fillna(0.0)
def _mad(x):
x = np.asarray(x, dtype=float)
return median_abs_deviation(x, scale=1.0, nan_policy="omit")
def _prop_zeros(x):
x = np.asarray(x, dtype=float)
return np.mean(x == 0.0)
def evaluar_estabilidad_robusta(
s_train, s_test,
alpha_pvalue=0.05,
umbral_mediana=0.30,
umbral_mad_hi=1.75,
umbral_wape=0.50,
umbral_anual=0.30,
umbral_delta_zeros=0.30,
regla_mayoria_k=2
):
s_train = s_train.astype(float); s_test = s_test.astype(float)
# nivel naïve = mediana del último año del train
train_yrs = s_train.index.year
y_max = train_yrs.max() if len(train_yrs) else 2024
s_train_last_year = s_train[train_yrs == y_max] if (train_yrs == y_max).any() else s_train
med_tr, med_te = np.median(s_train), np.median(s_test)
ratio_mediana = np.inf if med_tr == 0 else abs(med_te - med_tr) / abs(med_tr)
mad_tr, mad_te = _mad(s_train), _mad(s_test)
ratio_mad = (np.inf if mad_tr == 0 and mad_te > 0 else (1.0 if mad_tr == 0 else mad_te / mad_tr))
if s_train.nunique() > 1 and s_test.nunique() > 1:
_, p_value = mannwhitneyu(s_train.values, s_test.values, alternative="two-sided")
else:
p_value = 1.0
denom = s_test.abs().sum()
naive_level = float(np.median(s_train_last_year))
wape = np.inf if denom == 0 else (s_test - naive_level).abs().sum() / denom
train_by_year = s_train.groupby(s_train.index.year).sum()
ratio_anual = (np.inf if len(train_by_year)==0 or train_by_year.mean()==0
else abs(s_test.sum() - train_by_year.mean()) / abs(train_by_year.mean()))
dz = abs(_prop_zeros(s_test) - _prop_zeros(s_train))
flags = dict(
flag_mediana = ratio_mediana > umbral_mediana,
flag_mad = (ratio_mad > umbral_mad_hi) or (ratio_mad < 1.0/umbral_mad_hi),
flag_pvalue = p_value < alpha_pvalue,
flag_wape = wape > umbral_wape,
flag_anual = ratio_anual > umbral_anual,
flag_sparsidad = dz > umbral_delta_zeros
)
n_flags = sum(int(v) for v in flags.values())
clasif = "CAMBIO" if n_flags >= regla_mayoria_k else "ESTABLE"
return {
"ratio_mediana": ratio_mediana,
"ratio_mad": ratio_mad,
"p_value": p_value,
"WAPE_naive_anual": wape,
"ratio_anual": ratio_anual,
"delta_prop_zeros": dz,
**{k:int(v) for k,v in flags.items()},
"n_flags": n_flags,
"clasificacion_estad_global_train_vs_test": clasif
}
Ampliamos escenarios de la variable coste con el tratamiento de outliers.¶
Enriquecemos con variables de coste aplanando outliers o promediando outliers para los positivos y para los negativos reparte proporcionalmente.
# =========================================
# 1) Enriquecemos SOLO el TRAIN ITE2
# (partimos de df_fd1_v5_ITE2_train ya creado por tu pipeline)
# =========================================
# Si no existen mod_2/mod_3, los generamos
if not {"cost_float_mod_2","cost_float_mod_3"}.issubset(df_fd1_v5_ITE2_train.columns):
df_ite2_train_enr = transformar_panel_outliers(
df_fd1_v5_ITE2_train,
id_cols=("ID_BUILDING","FM_COST_TYPE"),
date_col="FECHA",
value_col="cost_float_mod",
asegurar_mensual=True
)
else:
df_ite2_train_enr = df_fd1_v5_ITE2_train.copy()
print("ITE2 train enriquecido:", df_ite2_train_enr.shape)
Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2078.2750 | lim_sup: 3932.0050 Límites IQR -> lim_inf: -856.7375 | lim_sup: 1472.5025 Límites IQR -> lim_inf: -93.0712 | lim_sup: 155.1187 Límites IQR -> lim_inf: 5981.2850 | lim_sup: 6439.7250 Límites IQR -> lim_inf: -282.4875 | lim_sup: 470.8125 Límites IQR -> lim_inf: -10163.2637 | lim_sup: 28464.8662 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -423.7500 | lim_sup: 706.2500 Límites IQR -> lim_inf: -45.1412 | lim_sup: 3500.4288 Límites IQR -> lim_inf: -1224.6862 | lim_sup: 3603.7238 Límites IQR -> lim_inf: -907.7400 | lim_sup: 1512.9000 Límites IQR -> lim_inf: 18521.7138 | lim_sup: 36733.5438 Límites IQR -> lim_inf: -345.3050 | lim_sup: 736.8150 Límites IQR -> lim_inf: -3318.7075 | lim_sup: 19606.9725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -16.8750 | lim_sup: 28.1250 Límites IQR -> lim_inf: 248.0512 | lim_sup: 3330.1013 Límites IQR -> lim_inf: -1157.6275 | lim_sup: 2280.3325 Límites IQR -> lim_inf: -7058.7888 | lim_sup: 11928.6813 Límites IQR -> lim_inf: -9593.2913 | lim_sup: 30422.8388 Límites IQR -> lim_inf: -555.3937 | lim_sup: 1117.8762 Límites IQR -> lim_inf: -1214.0600 | lim_sup: 11667.9400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -428.9100 | lim_sup: 714.8500 Límites IQR -> lim_inf: 200.8400 | lim_sup: 200.8400 Límites IQR -> lim_inf: -451.5113 | lim_sup: 752.5187 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -278.7675 | lim_sup: 464.6125 Límites IQR -> lim_inf: -1232.8050 | lim_sup: 2054.6750 Límites IQR -> lim_inf: -929.8013 | lim_sup: 1549.6688 Límites IQR -> lim_inf: -2944.4562 | lim_sup: 5382.5537 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -832.0950 | lim_sup: 1386.8250 Límites IQR -> lim_inf: 4350.0000 | lim_sup: 4350.0000 Límites IQR -> lim_inf: 168.6800 | lim_sup: 2061.8000 Límites IQR -> lim_inf: -387.9562 | lim_sup: 646.5938 Límites IQR -> lim_inf: -611.7463 | lim_sup: 3207.1438 Límites IQR -> lim_inf: 165.0500 | lim_sup: 165.0500 Límites IQR -> lim_inf: -86.8650 | lim_sup: 144.7750 Límites IQR -> lim_inf: -159.5413 | lim_sup: 804.8088 Límites IQR -> lim_inf: -284.9562 | lim_sup: 1651.4937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3448.1400 | lim_sup: 4846.3800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -383.0037 | lim_sup: 6872.2262 Límites IQR -> lim_inf: 248.2500 | lim_sup: 248.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 165.3000 | lim_sup: 224.1000 Límites IQR -> lim_inf: -1735.0063 | lim_sup: 3688.7038 Límites IQR -> lim_inf: 5201.3038 | lim_sup: 6853.6937 Límites IQR -> lim_inf: -259.5000 | lim_sup: 432.5000 Límites IQR -> lim_inf: -1824.3400 | lim_sup: 11960.9400 Límites IQR -> lim_inf: 158.7200 | lim_sup: 158.7200 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 162.1700 | lim_sup: 260.1700 Límites IQR -> lim_inf: -814.4937 | lim_sup: 2332.9562 Límites IQR -> lim_inf: 2305.1862 | lim_sup: 3028.5563 Límites IQR -> lim_inf: -338.8050 | lim_sup: 564.6750 Límites IQR -> lim_inf: -1872.0812 | lim_sup: 8788.0887 Límites IQR -> lim_inf: 102.1100 | lim_sup: 102.1100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -132.7600 | lim_sup: 723.9600 Límites IQR -> lim_inf: -1192.3962 | lim_sup: 2618.0937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1686.5000 | lim_sup: 2354.9800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -500.8763 | lim_sup: 5382.0938 Límites IQR -> lim_inf: 249.5200 | lim_sup: 249.5200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -430.8150 | lim_sup: 1272.9050 Límites IQR -> lim_inf: -1033.0050 | lim_sup: 3077.0950 Límites IQR -> lim_inf: 3320.5762 | lim_sup: 4910.1863 Límites IQR -> lim_inf: -532.6350 | lim_sup: 887.7250 Límites IQR -> lim_inf: -451.3275 | lim_sup: 10185.5725 Límites IQR -> lim_inf: 240.0800 | lim_sup: 240.0800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 227.9550 | lim_sup: 365.7550 Límites IQR -> lim_inf: -920.8600 | lim_sup: 2384.7200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3041.5800 | lim_sup: 5319.7400 Límites IQR -> lim_inf: -604.5900 | lim_sup: 1007.6500 Límites IQR -> lim_inf: -1116.9200 | lim_sup: 9515.6800 Límites IQR -> lim_inf: 157.1100 | lim_sup: 157.1100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -619.4175 | lim_sup: 1032.3625 Límites IQR -> lim_inf: -1092.4675 | lim_sup: 2673.1125 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 1509.0650 | lim_sup: 3217.6650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -367.1188 | lim_sup: 6661.8513 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -412.6313 | lim_sup: 1071.0188 Límites IQR -> lim_inf: -920.9638 | lim_sup: 1664.6863 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 1799.2750 | lim_sup: 2845.4750 Límites IQR -> lim_inf: -183.2138 | lim_sup: 305.3562 Límites IQR -> lim_inf: -244.1562 | lim_sup: 3445.5137 Límites IQR -> lim_inf: -10193.4000 | lim_sup: 16989.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -530.0625 | lim_sup: 1112.4375 Límites IQR -> lim_inf: -788.8500 | lim_sup: 2349.2500 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 3661.9475 | lim_sup: 5418.8875 Límites IQR -> lim_inf: -322.0312 | lim_sup: 536.7188 Límites IQR -> lim_inf: -2259.7500 | lim_sup: 9549.9700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -822.1950 | lim_sup: 1370.3250 Límites IQR -> lim_inf: -575.3100 | lim_sup: 1285.0100 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 1805.3187 | lim_sup: 2704.3887 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -264.7775 | lim_sup: 4324.1025 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 541.2500 | lim_sup: 803.2500 Límites IQR -> lim_inf: -1445.3438 | lim_sup: 2508.7063 Límites IQR -> lim_inf: -139.6050 | lim_sup: 232.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1730.0738 | lim_sup: 9740.2763 Límites IQR -> lim_inf: 211.9400 | lim_sup: 211.9400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 257.7450 | lim_sup: 290.1050 Límites IQR -> lim_inf: -1230.2300 | lim_sup: 2944.6100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -8.2500 | lim_sup: 453.7500 Límites IQR -> lim_inf: -828.8400 | lim_sup: 1381.4000 Límites IQR -> lim_inf: -594.0137 | lim_sup: 8409.8562 Límites IQR -> lim_inf: 43.3300 | lim_sup: 43.3300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -194.2500 | lim_sup: 323.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 218.5700 | lim_sup: 218.5700 Límites IQR -> lim_inf: -259.0875 | lim_sup: 1915.7725 Límites IQR -> lim_inf: 262.9000 | lim_sup: 262.9000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 282.5625 | lim_sup: 4404.2225 Límites IQR -> lim_inf: -747.5300 | lim_sup: 1762.7500 Límites IQR -> lim_inf: -100.8450 | lim_sup: 168.0750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2519.2125 | lim_sup: 10976.8675 Límites IQR -> lim_inf: 368.3900 | lim_sup: 368.3900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -211.7050 | lim_sup: 1577.8550 Límites IQR -> lim_inf: -624.2313 | lim_sup: 3273.1388 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4309.5550 | lim_sup: 5658.2350 Límites IQR -> lim_inf: -318.4275 | lim_sup: 530.7125 Límites IQR -> lim_inf: -1090.1950 | lim_sup: 14285.0650 Límites IQR -> lim_inf: -12960.0000 | lim_sup: 21600.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -458.6938 | lim_sup: 1544.6363 Límites IQR -> lim_inf: -1327.7613 | lim_sup: 3527.9887 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1338.3100 | lim_sup: 6080.8700 Límites IQR -> lim_inf: -200.8125 | lim_sup: 334.6875 Límites IQR -> lim_inf: -3950.8625 | lim_sup: 15999.2775 Límites IQR -> lim_inf: -4666.5000 | lim_sup: 7777.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -965.7312 | lim_sup: 2373.0387 Límites IQR -> lim_inf: -1615.8112 | lim_sup: 3935.8387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3140.1525 | lim_sup: 4298.4125 Límites IQR -> lim_inf: -222.0000 | lim_sup: 370.0000 Límites IQR -> lim_inf: -2095.3200 | lim_sup: 13647.3400 Límites IQR -> lim_inf: 225.6900 | lim_sup: 225.6900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 102.2750 | lim_sup: 338.6350 Límites IQR -> lim_inf: -1028.1650 | lim_sup: 2116.2750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3220.1950 | lim_sup: 4473.5150 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -890.0150 | lim_sup: 8804.3250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 239.0000 | lim_sup: 239.0000 Límites IQR -> lim_inf: -720.6262 | lim_sup: 7274.2237 Límites IQR -> lim_inf: 235.3300 | lim_sup: 235.3300 Límites IQR -> lim_inf: -104.9625 | lim_sup: 174.9375 Límites IQR -> lim_inf: 384.0000 | lim_sup: 384.0000 Límites IQR -> lim_inf: -632.3662 | lim_sup: 1102.5437 Límites IQR -> lim_inf: 753.7000 | lim_sup: 2541.4600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1097.3763 | lim_sup: 9410.2938 Límites IQR -> lim_inf: 192.1700 | lim_sup: 192.1700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 384.0000 | lim_sup: 384.0000 Límites IQR -> lim_inf: -267.3900 | lim_sup: 445.6500 Límites IQR -> lim_inf: 806.8925 | lim_sup: 2442.8325 Límites IQR -> lim_inf: -327.8550 | lim_sup: 546.4250 Límites IQR -> lim_inf: -2131.1337 | lim_sup: 9103.7762 Límites IQR -> lim_inf: 190.0700 | lim_sup: 190.0700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 40.8750 | lim_sup: 251.8750 Límites IQR -> lim_inf: -1174.3775 | lim_sup: 2861.7225 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1944.6000 | lim_sup: 2995.3200 Límites IQR -> lim_inf: -458.1000 | lim_sup: 763.5000 Límites IQR -> lim_inf: 19.5388 | lim_sup: 7440.2287 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 120.0000 | lim_sup: 120.0000 Límites IQR -> lim_inf: -443.8913 | lim_sup: 739.8188 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 384.5037 | lim_sup: 2247.5338 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -122.0475 | lim_sup: 3762.4725 Límites IQR -> lim_inf: 186.3800 | lim_sup: 186.3800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -542.1875 | lim_sup: 1895.4325 Límites IQR -> lim_inf: -780.3188 | lim_sup: 1840.6113 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2935.4550 | lim_sup: 3815.4950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1129.5788 | lim_sup: 9287.6513 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -428.4700 | lim_sup: 1993.0500 Límites IQR -> lim_inf: -1133.9900 | lim_sup: 3390.8700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4496.3988 | lim_sup: 5515.3288 Límites IQR -> lim_inf: -75.7500 | lim_sup: 126.2500 Límites IQR -> lim_inf: 177.1025 | lim_sup: 10289.9625 Límites IQR -> lim_inf: 169.7500 | lim_sup: 169.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -473.3700 | lim_sup: 1188.9500 Límites IQR -> lim_inf: -1373.8187 | lim_sup: 2766.9512 Límites IQR -> lim_inf: 1054.1950 | lim_sup: 4632.1550 Límites IQR -> lim_inf: -340.9500 | lim_sup: 568.2500 Límites IQR -> lim_inf: -905.8450 | lim_sup: 7174.9550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -449.8350 | lim_sup: 749.7250 Límites IQR -> lim_inf: -34.6075 | lim_sup: 902.6125 Límites IQR -> lim_inf: -277.5000 | lim_sup: 462.5000 Límites IQR -> lim_inf: -147.1775 | lim_sup: 919.0225 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -217.2225 | lim_sup: 362.0375 Límites IQR -> lim_inf: -199.3550 | lim_sup: 1127.2450 Límites IQR -> lim_inf: -234.8438 | lim_sup: 391.4062 Límites IQR -> lim_inf: -120.1800 | lim_sup: 922.5800 Límites IQR -> lim_inf: -1053.7700 | lim_sup: 3449.6300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -33.6300 | lim_sup: 541.8900 Límites IQR -> lim_inf: -843.6425 | lim_sup: 1563.7375 Límites IQR -> lim_inf: -2262.1713 | lim_sup: 11791.4388 Límites IQR -> lim_inf: -2421.8850 | lim_sup: 7049.1950 Límites IQR -> lim_inf: -264.0000 | lim_sup: 440.0000 Límites IQR -> lim_inf: -451.8313 | lim_sup: 3436.1588 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -892.4362 | lim_sup: 1487.3937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -93.3800 | lim_sup: 1086.8600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -71.7300 | lim_sup: 889.0300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -121.5000 | lim_sup: 202.5000 Límites IQR -> lim_inf: -1358.8800 | lim_sup: 2487.5600 Límites IQR -> lim_inf: 381.4400 | lim_sup: 1408.4800 Límites IQR -> lim_inf: -471.7687 | lim_sup: 786.2812 Límites IQR -> lim_inf: -391.3087 | lim_sup: 2281.1812 Límites IQR -> lim_inf: 60.0000 | lim_sup: 60.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -315.9075 | lim_sup: 526.5125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 125.3050 | lim_sup: 666.5450 Límites IQR -> lim_inf: -300.7500 | lim_sup: 501.2500 Límites IQR -> lim_inf: -280.7712 | lim_sup: 1406.4987 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -265.0400 | lim_sup: 596.8000 Límites IQR -> lim_inf: -123.6150 | lim_sup: 206.0250 Límites IQR -> lim_inf: -53.6513 | lim_sup: 344.5987 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.8550 | lim_sup: 251.4250 Límites IQR -> lim_inf: -414.3750 | lim_sup: 690.6250 Límites IQR -> lim_inf: 464.9200 | lim_sup: 464.9200 Límites IQR -> lim_inf: -835.3687 | lim_sup: 1392.2812 Límites IQR -> lim_inf: -467.7700 | lim_sup: 1207.7300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -530.2125 | lim_sup: 883.6875 Límites IQR -> lim_inf: 283.2500 | lim_sup: 401.6500 Límites IQR -> lim_inf: -330.0000 | lim_sup: 550.0000 Límites IQR -> lim_inf: -302.5975 | lim_sup: 1388.8025 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -539.8125 | lim_sup: 899.6875 Límites IQR -> lim_inf: -1520.6925 | lim_sup: 3177.6875 Límites IQR -> lim_inf: 11.1050 | lim_sup: 883.3050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -132.1900 | lim_sup: 1236.2300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -331.6050 | lim_sup: 552.6750 Límites IQR -> lim_inf: -762.7500 | lim_sup: 1271.2500 Límites IQR -> lim_inf: 308.3000 | lim_sup: 437.1800 Límites IQR -> lim_inf: -210.0000 | lim_sup: 350.0000 Límites IQR -> lim_inf: -156.5237 | lim_sup: 1122.9462 Límites IQR -> lim_inf: -225.0000 | lim_sup: 375.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -992.6150 | lim_sup: 2118.9450 Límites IQR -> lim_inf: 1235.8300 | lim_sup: 1440.1500 Límites IQR -> lim_inf: -127.1850 | lim_sup: 211.9750 Límites IQR -> lim_inf: -104.5813 | lim_sup: 1234.0688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -518.2313 | lim_sup: 863.7188 Límites IQR -> lim_inf: -2833.3700 | lim_sup: 8500.1100 Límites IQR -> lim_inf: -319.8750 | lim_sup: 1042.3250 Límites IQR -> lim_inf: -480.0000 | lim_sup: 800.0000 Límites IQR -> lim_inf: -3.4312 | lim_sup: 734.1987 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -339.9300 | lim_sup: 566.5500 Límites IQR -> lim_inf: 152.1350 | lim_sup: 736.2550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -240.7050 | lim_sup: 909.8350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2065.5500 | lim_sup: 10120.4500 Límites IQR -> lim_inf: 289.4800 | lim_sup: 289.4800 Límites IQR -> lim_inf: -165.0000 | lim_sup: 355.0000 Límites IQR -> lim_inf: -311.8763 | lim_sup: 635.9738 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 15.0000 | lim_sup: 15.0000 Límites IQR -> lim_inf: 89.8400 | lim_sup: 127.3600 Límites IQR -> lim_inf: 0.8612 | lim_sup: 75.6713 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -367.7887 | lim_sup: 612.9813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -194.9475 | lim_sup: 1601.6325 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -298.2300 | lim_sup: 497.0500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -285.2587 | lim_sup: 2059.8312 Límites IQR -> lim_inf: 52.0100 | lim_sup: 52.0100 Límites IQR -> lim_inf: -270.0000 | lim_sup: 450.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -510.4050 | lim_sup: 850.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -816.0413 | lim_sup: 2693.6888 Límites IQR -> lim_inf: 57.3700 | lim_sup: 57.3700 Límites IQR -> lim_inf: -135.0000 | lim_sup: 225.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -331.9800 | lim_sup: 553.3000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -294.0900 | lim_sup: 2412.0300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -382.7212 | lim_sup: 637.8687 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -57.0725 | lim_sup: 185.7875 Límites IQR -> lim_inf: -166.6313 | lim_sup: 1586.9588 Límites IQR -> lim_inf: 50.4000 | lim_sup: 50.4000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -328.5113 | lim_sup: 547.5187 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -285.0750 | lim_sup: 2420.7050 Límites IQR -> lim_inf: 45.1400 | lim_sup: 45.1400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -265.7250 | lim_sup: 442.8750 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.8188 | lim_sup: 1935.0113 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -585.4425 | lim_sup: 975.7375 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -615.6800 | lim_sup: 2887.4600 Límites IQR -> lim_inf: 49.6000 | lim_sup: 49.6000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -279.1875 | lim_sup: 719.0325 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -95.0475 | lim_sup: 2124.6925 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -420.2925 | lim_sup: 700.4875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -1183.0775 | lim_sup: 3482.3825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.5000 | lim_sup: 337.5000 Límites IQR -> lim_inf: -494.7750 | lim_sup: 824.6250 Límites IQR -> lim_inf: 43.7500 | lim_sup: 43.7500 Límites IQR -> lim_inf: 96.8750 | lim_sup: 117.8750 Límites IQR -> lim_inf: -45.5363 | lim_sup: 75.8938 Límites IQR -> lim_inf: -361.5350 | lim_sup: 2290.5450 Límites IQR -> lim_inf: 77.1600 | lim_sup: 77.1600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -566.8438 | lim_sup: 1363.5263 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -260.0725 | lim_sup: 3017.1275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -88.1250 | lim_sup: 146.8750 Límites IQR -> lim_inf: -305.2912 | lim_sup: 508.8188 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -45.5363 | lim_sup: 75.8938 Límites IQR -> lim_inf: 138.6525 | lim_sup: 1647.8325 Límites IQR -> lim_inf: 59.9100 | lim_sup: 59.9100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -332.4600 | lim_sup: 554.1000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -291.6137 | lim_sup: 2579.4762 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -329.2500 | lim_sup: 548.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.0450 | lim_sup: 175.0750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -5.6913 | lim_sup: 1353.5387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -368.2800 | lim_sup: 613.8000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -91.0725 | lim_sup: 151.7875 Límites IQR -> lim_inf: -204.5338 | lim_sup: 1704.9763 Límites IQR -> lim_inf: -95.2500 | lim_sup: 158.7500 Límites IQR -> lim_inf: -93.0000 | lim_sup: 155.0000 Límites IQR -> lim_inf: -217.5000 | lim_sup: 362.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -127.1625 | lim_sup: 1515.7975 Límites IQR -> lim_inf: 44.1400 | lim_sup: 44.1400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -110.1375 | lim_sup: 183.5625 Límites IQR -> lim_inf: -197.4975 | lim_sup: 329.1625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -165.1300 | lim_sup: 1942.0900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -631.0538 | lim_sup: 1051.7562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -251.1162 | lim_sup: 2123.0138 Límites IQR -> lim_inf: 104.2800 | lim_sup: 104.2800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -209.4750 | lim_sup: 349.1250 Límites IQR -> lim_inf: -608.9688 | lim_sup: 1074.8412 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -159.9600 | lim_sup: 706.6000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1026.1950 | lim_sup: 4600.7850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -135.4950 | lim_sup: 225.8250 Límites IQR -> lim_inf: -350.5238 | lim_sup: 584.2062 Límites IQR -> lim_inf: 32.7500 | lim_sup: 238.7500 Límites IQR -> lim_inf: -203.5725 | lim_sup: 339.2875 Límites IQR -> lim_inf: -270.7175 | lim_sup: 2244.8425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -133.5750 | lim_sup: 222.6250 Límites IQR -> lim_inf: -200.6662 | lim_sup: 334.4438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -72.5350 | lim_sup: 1877.3450 Límites IQR -> lim_inf: 49.8800 | lim_sup: 49.8800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -128.2500 | lim_sup: 213.7500 Límites IQR -> lim_inf: -440.6775 | lim_sup: 734.4625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: 30.8113 | lim_sup: 1904.5412 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -225.9937 | lim_sup: 376.6562 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 6.1938 | lim_sup: 160.3438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -16.0075 | lim_sup: 1741.7525 Límites IQR -> lim_inf: -313.1250 | lim_sup: 521.8750 Límites IQR -> lim_inf: -150.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -400.4925 | lim_sup: 667.4875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -255.1075 | lim_sup: 2114.9725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -137.0400 | lim_sup: 228.4000 Límites IQR -> lim_inf: -412.2013 | lim_sup: 777.2088 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 92.5000 | lim_sup: 120.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -136.4962 | lim_sup: 2299.1137 Límites IQR -> lim_inf: 48.7400 | lim_sup: 48.7400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -164.7188 | lim_sup: 274.5312 Límites IQR -> lim_inf: -153.1650 | lim_sup: 255.2750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 55.0000 | lim_sup: 55.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -479.5562 | lim_sup: 1934.9337 Límites IQR -> lim_inf: 55.7000 | lim_sup: 55.7000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.0000 | lim_sup: 175.0000 Límites IQR -> lim_inf: -405.5475 | lim_sup: 852.4725 Límites IQR -> lim_inf: -87.5475 | lim_sup: 145.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -99.4400 | lim_sup: 2087.7800 Límites IQR -> lim_inf: 40.1700 | lim_sup: 40.1700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -106.5000 | lim_sup: 177.5000 Límites IQR -> lim_inf: -357.3250 | lim_sup: 736.8750 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -322.3438 | lim_sup: 2121.1463 Límites IQR -> lim_inf: 45.6600 | lim_sup: 45.6600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -427.6425 | lim_sup: 712.7375 Límites IQR -> lim_inf: -87.5475 | lim_sup: 145.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -203.6937 | lim_sup: 1948.9762 Límites IQR -> lim_inf: 62.0300 | lim_sup: 62.0300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -445.1963 | lim_sup: 741.9938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -355.5150 | lim_sup: 2656.7850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -45.0000 | lim_sup: 75.0000 Límites IQR -> lim_inf: -323.7750 | lim_sup: 539.6250 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -292.5900 | lim_sup: 1822.4100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -106.5000 | lim_sup: 177.5000 Límites IQR -> lim_inf: -340.8750 | lim_sup: 568.1250 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -141.9775 | lim_sup: 1563.4425 Límites IQR -> lim_inf: 72.0000 | lim_sup: 72.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -88.1250 | lim_sup: 146.8750 Límites IQR -> lim_inf: -604.5900 | lim_sup: 1007.6500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -505.2775 | lim_sup: 3428.9025 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -144.6600 | lim_sup: 241.1000 Límites IQR -> lim_inf: -322.2750 | lim_sup: 537.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 102.7950 | lim_sup: 134.6750 Límites IQR -> lim_inf: -52.5000 | lim_sup: 87.5000 Límites IQR -> lim_inf: -21.2937 | lim_sup: 2468.4762 Límites IQR -> lim_inf: 50.6000 | lim_sup: 50.6000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -516.1950 | lim_sup: 860.3250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: -289.6950 | lim_sup: 2115.5050 Límites IQR -> lim_inf: 1112.0000 | lim_sup: 1112.0000 Límites IQR -> lim_inf: -200.2200 | lim_sup: 333.7000 Límites IQR -> lim_inf: -584.8500 | lim_sup: 974.7500 Límites IQR -> lim_inf: -956.7350 | lim_sup: 2099.8650 Límites IQR -> lim_inf: 361.9950 | lim_sup: 513.3150 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.8225 | lim_sup: 349.8025 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -540.7500 | lim_sup: 901.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -170.3413 | lim_sup: 1807.2088 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -174.0000 | lim_sup: 290.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -190.6500 | lim_sup: 317.7500 Límites IQR -> lim_inf: -376.6163 | lim_sup: 627.6938 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -23.9438 | lim_sup: 1657.3663 Límites IQR -> lim_inf: 53.2100 | lim_sup: 53.2100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -340.2250 | lim_sup: 754.5350 Límites IQR -> lim_inf: 92.5000 | lim_sup: 120.5000 Límites IQR -> lim_inf: -45.5363 | lim_sup: 75.8938 Límites IQR -> lim_inf: -191.8325 | lim_sup: 2125.6275 Límites IQR -> lim_inf: -90.7500 | lim_sup: 151.2500 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -529.9750 | lim_sup: 1129.0650 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -187.6088 | lim_sup: 312.6813 Límites IQR -> lim_inf: -258.5287 | lim_sup: 2258.8812 Límites IQR -> lim_inf: 57.7100 | lim_sup: 57.7100 Límites IQR -> lim_inf: -90.7500 | lim_sup: 151.2500 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -426.7162 | lim_sup: 711.1937 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -102.1725 | lim_sup: 170.2875 Límites IQR -> lim_inf: -319.2900 | lim_sup: 2499.1700 Límites IQR -> lim_inf: 46.1400 | lim_sup: 46.1400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -429.1462 | lim_sup: 715.2437 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -290.3375 | lim_sup: 2020.0825 Límites IQR -> lim_inf: 40.4400 | lim_sup: 40.4400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -370.5750 | lim_sup: 617.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 19.1950 | lim_sup: 1657.5150 Límites IQR -> lim_inf: 45.2900 | lim_sup: 45.2900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -660.4725 | lim_sup: 1100.7875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -146.0138 | lim_sup: 1962.2963 Límites IQR -> lim_inf: 59.5900 | lim_sup: 59.5900 Límites IQR -> lim_inf: -125.0000 | lim_sup: 375.0000 Límites IQR -> lim_inf: -13.2900 | lim_sup: 22.1500 Límites IQR -> lim_inf: -848.2500 | lim_sup: 1413.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -79.0838 | lim_sup: 2399.9263 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.5475 | lim_sup: 250.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 35.2488 | lim_sup: 1718.2987 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -233.4150 | lim_sup: 389.0250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -87.5362 | lim_sup: 145.8938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -158.2400 | lim_sup: 1919.8000 Límites IQR -> lim_inf: -180.2775 | lim_sup: 300.4625 Límites IQR -> lim_inf: -1525.8600 | lim_sup: 2783.1000 Límites IQR -> lim_inf: -712.5425 | lim_sup: 1851.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1797.4900 | lim_sup: 5118.6100 Límites IQR -> lim_inf: -217.7775 | lim_sup: 362.9625 Límites IQR -> lim_inf: -1494.8288 | lim_sup: 7866.9213 Límites IQR -> lim_inf: 499.3900 | lim_sup: 499.3900 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: -78.4900 | lim_sup: 1101.2900 Límites IQR -> lim_inf: -447.1200 | lim_sup: 2026.1200 Límites IQR -> lim_inf: 492.1600 | lim_sup: 492.1600 Límites IQR -> lim_inf: 4053.9725 | lim_sup: 7733.5125 Límites IQR -> lim_inf: -454.6200 | lim_sup: 757.7000 Límites IQR -> lim_inf: -2755.3350 | lim_sup: 20245.3650 Límites IQR -> lim_inf: 57.2300 | lim_sup: 57.2300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.0000 | lim_sup: 175.0000 Límites IQR -> lim_inf: -198.0000 | lim_sup: 330.0000 Límites IQR -> lim_inf: -87.5475 | lim_sup: 145.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -511.8400 | lim_sup: 2842.9400 Límites IQR -> lim_inf: -187.5000 | lim_sup: 562.5000 Límites IQR -> lim_inf: -64.5000 | lim_sup: 107.5000 Límites IQR -> lim_inf: -306.7500 | lim_sup: 511.2500 Límites IQR -> lim_inf: -68.6250 | lim_sup: 114.3750 Límites IQR -> lim_inf: 94.8000 | lim_sup: 94.8000 Límites IQR -> lim_inf: -325.7850 | lim_sup: 2373.5350 Límites IQR -> lim_inf: 79.8000 | lim_sup: 79.8000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -276.0000 | lim_sup: 460.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -51.0075 | lim_sup: 85.0125 Límites IQR -> lim_inf: -409.6250 | lim_sup: 3023.4750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 500.0000 | lim_sup: 500.0000 Límites IQR -> lim_inf: 85.2750 | lim_sup: 97.8750 Límites IQR -> lim_inf: 4.1087 | lim_sup: 236.4188 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -78.2000 | lim_sup: 363.4000 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -509.8050 | lim_sup: 849.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 60.3675 | lim_sup: 109.5075 Límites IQR -> lim_inf: 63.1500 | lim_sup: 63.1500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -694.0500 | lim_sup: 1156.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -244.0088 | lim_sup: 2672.2013 Límites IQR -> lim_inf: 56.3000 | lim_sup: 56.3000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -328.9500 | lim_sup: 548.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -87.5475 | lim_sup: 145.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -149.3775 | lim_sup: 2298.2025 Límites IQR -> lim_inf: 259.1000 | lim_sup: 259.1000 Límites IQR -> lim_inf: -95.6250 | lim_sup: 159.3750 Límites IQR -> lim_inf: 30.8900 | lim_sup: 30.8900 Límites IQR -> lim_inf: 34.8700 | lim_sup: 34.8700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -437.2500 | lim_sup: 728.7500 Límites IQR -> lim_inf: 92.5000 | lim_sup: 120.5000 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -263.2650 | lim_sup: 1669.4550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -146.1000 | lim_sup: 243.5000 Límites IQR -> lim_inf: -638.9250 | lim_sup: 1064.8750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -235.6713 | lim_sup: 2385.8387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2866.8700 | lim_sup: 7031.2700 Límites IQR -> lim_inf: -3623.4200 | lim_sup: 13964.6800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 16252.4512 | lim_sup: 22178.4813 Límites IQR -> lim_inf: -2398.3763 | lim_sup: 5225.8138 Límites IQR -> lim_inf: -6386.3850 | lim_sup: 62980.3950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -15.0000 | lim_sup: 25.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -84.0000 | lim_sup: 140.0000 Límites IQR -> lim_inf: -58.4862 | lim_sup: 2115.1237 Límites IQR -> lim_inf: -337.5000 | lim_sup: 562.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -128.4300 | lim_sup: 214.0500 Límites IQR -> lim_inf: 380.6650 | lim_sup: 455.5050 Límites IQR -> lim_inf: -207.7188 | lim_sup: 988.5713 Límites IQR -> lim_inf: 46.3600 | lim_sup: 46.3600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.0000 | lim_sup: 175.0000 Límites IQR -> lim_inf: -373.2000 | lim_sup: 622.0000 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -300.9425 | lim_sup: 2147.5575 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -169.2975 | lim_sup: 282.1625 Límites IQR -> lim_inf: -405.9000 | lim_sup: 2381.3800 Límites IQR -> lim_inf: -1941.2175 | lim_sup: 3584.5225 Límites IQR -> lim_inf: -30.1275 | lim_sup: 50.2125 Límites IQR -> lim_inf: 2837.0700 | lim_sup: 5892.9100 Límites IQR -> lim_inf: -1088.9013 | lim_sup: 2425.1888 Límites IQR -> lim_inf: -6979.5900 | lim_sup: 24141.4900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -150.7013 | lim_sup: 251.1688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 180.0000 | lim_sup: 180.0000 Límites IQR -> lim_inf: -95.0188 | lim_sup: 752.7713 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 122.5400 | lim_sup: 173.7400 Límites IQR -> lim_inf: -45.8563 | lim_sup: 197.4938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -65.0025 | lim_sup: 108.3375 Límites IQR -> lim_inf: 181.2000 | lim_sup: 256.9600 Límites IQR -> lim_inf: -30.4975 | lim_sup: 302.1625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -174.4137 | lim_sup: 358.9963 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.6450 | lim_sup: 437.0750 Límites IQR -> lim_inf: 59.3750 | lim_sup: 534.3750 Límites IQR -> lim_inf: -269.3688 | lim_sup: 691.4813 Límites IQR -> lim_inf: 48.3700 | lim_sup: 48.3700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -137.1750 | lim_sup: 228.6250 Límites IQR -> lim_inf: -422.9625 | lim_sup: 704.9375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -45.5363 | lim_sup: 75.8938 Límites IQR -> lim_inf: -53.2537 | lim_sup: 1918.4962 Límites IQR -> lim_inf: 67.2100 | lim_sup: 67.2100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -162.7350 | lim_sup: 271.2250 Límites IQR -> lim_inf: -525.2400 | lim_sup: 875.4000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -126.1250 | lim_sup: 2747.5150 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1850.0062 | lim_sup: 5907.8237 Límites IQR -> lim_inf: -4016.0213 | lim_sup: 14363.3888 Límites IQR -> lim_inf: -2688.0212 | lim_sup: 7716.6687 Límites IQR -> lim_inf: 6871.5487 | lim_sup: 15718.7788 Límites IQR -> lim_inf: -1670.5725 | lim_sup: 2784.2875 Límites IQR -> lim_inf: -5462.8038 | lim_sup: 53688.6062 Límites IQR -> lim_inf: -78.4648 | lim_sup: 727.8898 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -473.6420 | lim_sup: 3737.8216 Límites IQR -> lim_inf: 4223.9602 | lim_sup: 4722.0057 Límites IQR -> lim_inf: -338.2159 | lim_sup: 563.6932 Límites IQR -> lim_inf: 11589.4614 | lim_sup: 17066.7159 Límites IQR -> lim_inf: 54.3602 | lim_sup: 506.5148 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1165.9727 | lim_sup: 3651.3727 Límites IQR -> lim_inf: 2773.1591 | lim_sup: 7044.2500 Límites IQR -> lim_inf: -849.9682 | lim_sup: 1416.6136 Límites IQR -> lim_inf: 9924.4443 | lim_sup: 12992.1261 Límites IQR -> lim_inf: 80.9205 | lim_sup: 462.2477 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -1648.3989 | lim_sup: 4000.6648 Límites IQR -> lim_inf: 4403.5273 | lim_sup: 6487.4545 Límites IQR -> lim_inf: -772.8614 | lim_sup: 1288.1023 Límites IQR -> lim_inf: 7914.0795 | lim_sup: 12708.0068 Límites IQR -> lim_inf: 97.6750 | lim_sup: 514.2750 Límites IQR -> lim_inf: -472.7273 | lim_sup: 1003.6364 Límites IQR -> lim_inf: -1302.1648 | lim_sup: 5904.8080 Límites IQR -> lim_inf: 7186.9602 | lim_sup: 8112.3693 Límites IQR -> lim_inf: -941.8977 | lim_sup: 1569.8295 Límites IQR -> lim_inf: 15028.6500 | lim_sup: 21696.3591 Límites IQR -> lim_inf: -8.1420 | lim_sup: 222.5398 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -948.8216 | lim_sup: 2161.3693 Límites IQR -> lim_inf: 3446.7273 | lim_sup: 3810.3636 Límites IQR -> lim_inf: -115.9091 | lim_sup: 193.1818 Límites IQR -> lim_inf: 4688.1659 | lim_sup: 8851.8023 Límites IQR -> lim_inf: -78.7864 | lim_sup: 507.5773 Límites IQR -> lim_inf: 66.3636 | lim_sup: 131.8182 Límites IQR -> lim_inf: -947.5864 | lim_sup: 2449.4864 Límites IQR -> lim_inf: 3570.0455 | lim_sup: 3851.8636 Límites IQR -> lim_inf: -136.3636 | lim_sup: 227.2727 Límites IQR -> lim_inf: 6163.0011 | lim_sup: 10976.5011 Límites IQR -> lim_inf: -284.7716 | lim_sup: 838.8375 Límites IQR -> lim_inf: 88.6364 | lim_sup: 143.1818 Límites IQR -> lim_inf: -1328.3636 | lim_sup: 4016.5818 Límites IQR -> lim_inf: 5021.6830 | lim_sup: 5666.0011 Límites IQR -> lim_inf: -409.3977 | lim_sup: 682.3295 Límites IQR -> lim_inf: 8423.3534 | lim_sup: 12828.7625 Límites IQR -> lim_inf: 107.9136 | lim_sup: 320.6773 Límites IQR -> lim_inf: 88.6364 | lim_sup: 143.1818 Límites IQR -> lim_inf: -1369.6386 | lim_sup: 3682.5614 Límites IQR -> lim_inf: 4811.7455 | lim_sup: 5553.4909 Límites IQR -> lim_inf: -197.7273 | lim_sup: 329.5455 Límites IQR -> lim_inf: 6913.3420 | lim_sup: 11787.3875 Límites IQR -> lim_inf: 146.6932 | lim_sup: 521.9841 Límites IQR -> lim_inf: 1142.2727 | lim_sup: 1193.1818 Límites IQR -> lim_inf: -1832.0648 | lim_sup: 6377.8716 Límites IQR -> lim_inf: 5382.6705 | lim_sup: 5666.2159 Límites IQR -> lim_inf: -567.6136 | lim_sup: 946.0227 Límites IQR -> lim_inf: 15157.1000 | lim_sup: 27218.6818 Límites IQR -> lim_inf: -123.3182 | lim_sup: 802.6455 Límites IQR -> lim_inf: -104.5455 | lim_sup: 390.0000 Límites IQR -> lim_inf: -1075.1830 | lim_sup: 3544.4625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4423.0455 | lim_sup: 9191.2636 Límites IQR -> lim_inf: -545.4545 | lim_sup: 909.0909 Límites IQR -> lim_inf: 15465.7261 | lim_sup: 19526.0261 Límites IQR -> lim_inf: 89.4216 | lim_sup: 415.8125 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2375.2227 | lim_sup: 6025.1045 Límites IQR -> lim_inf: 3860.3182 | lim_sup: 8106.5000 Límites IQR -> lim_inf: -255.6818 | lim_sup: 426.1364 Límites IQR -> lim_inf: 8730.4330 | lim_sup: 15897.4602 Límites IQR -> lim_inf: 146.6932 | lim_sup: 521.9841 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -536.2784 | lim_sup: 3668.2398 Límites IQR -> lim_inf: 6364.0455 | lim_sup: 6890.2273 Límites IQR -> lim_inf: -255.6818 | lim_sup: 426.1364 Límites IQR -> lim_inf: 15458.2125 | lim_sup: 19323.1489 Límites IQR -> lim_inf: 139.1705 | lim_sup: 438.4977 Límites IQR -> lim_inf: -209.0909 | lim_sup: 634.5455 Límites IQR -> lim_inf: -1145.5273 | lim_sup: 3088.7273 Límites IQR -> lim_inf: 4415.2955 | lim_sup: 6228.7864 Límites IQR -> lim_inf: -982.7455 | lim_sup: 1637.9091 Límites IQR -> lim_inf: 6271.6716 | lim_sup: 13027.6807 Límites IQR -> lim_inf: 17.4443 | lim_sup: 665.3261 Límites IQR -> lim_inf: -267.7273 | lim_sup: 732.2727 Límites IQR -> lim_inf: -708.7023 | lim_sup: 5273.9341 Límites IQR -> lim_inf: 6099.0466 | lim_sup: 6886.6011 Límites IQR -> lim_inf: -618.3136 | lim_sup: 1030.5227 Límites IQR -> lim_inf: 11479.3034 | lim_sup: 16475.0216 Límites IQR -> lim_inf: 15.8148 | lim_sup: 668.0420 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2811.5080 | lim_sup: 6390.5375 Límites IQR -> lim_inf: 5933.7295 | lim_sup: 7636.3295 Límites IQR -> lim_inf: -1237.8182 | lim_sup: 2569.0909 Límites IQR -> lim_inf: 12384.0216 | lim_sup: 16511.0852 Límites IQR -> lim_inf: 90.6864 | lim_sup: 225.1227 Límites IQR -> lim_inf: 31.8182 | lim_sup: 68.1818 Límites IQR -> lim_inf: -947.8136 | lim_sup: 2184.0409 Límites IQR -> lim_inf: 3613.8989 | lim_sup: 3712.8352 Límites IQR -> lim_inf: -204.5455 | lim_sup: 340.9091 Límites IQR -> lim_inf: 5819.6932 | lim_sup: 12270.8568 Límites IQR -> lim_inf: 493.5364 | lim_sup: 493.5364 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1749.9773 | lim_sup: 6816.7136 Límites IQR -> lim_inf: 63.6364 | lim_sup: 63.6364 Límites IQR -> lim_inf: 7163.3636 | lim_sup: 7327.7273 Límites IQR -> lim_inf: -606.3727 | lim_sup: 1478.5000 Límites IQR -> lim_inf: 16345.3420 | lim_sup: 22563.2420 Límites IQR -> lim_inf: 62.2341 | lim_sup: 735.5250 Límites IQR -> lim_inf: 155.0273 | lim_sup: 155.0273 Límites IQR -> lim_inf: -1013.2864 | lim_sup: 2421.2955 Límites IQR -> lim_inf: 3547.0682 | lim_sup: 3618.8864 Límites IQR -> lim_inf: -216.2045 | lim_sup: 360.3409 Límites IQR -> lim_inf: 4740.0614 | lim_sup: 12041.9523 Límites IQR -> lim_inf: 54.3636 | lim_sup: 506.5091 Límites IQR -> lim_inf: 297.0000 | lim_sup: 297.0000 Límites IQR -> lim_inf: -2198.3091 | lim_sup: 6257.9818 Límites IQR -> lim_inf: 3541.1955 | lim_sup: 7804.3591 Límites IQR -> lim_inf: -681.8182 | lim_sup: 1136.3636 Límites IQR -> lim_inf: 19210.0216 | lim_sup: 27446.9761 Límites IQR -> lim_inf: 158.3750 | lim_sup: 406.4659 Límites IQR -> lim_inf: -1759.3000 | lim_sup: 4447.3182 Límites IQR -> lim_inf: -1279.0148 | lim_sup: 8934.7580 Límites IQR -> lim_inf: 5952.3773 | lim_sup: 7305.0682 Límites IQR -> lim_inf: -1399.4318 | lim_sup: 2332.3864 Límites IQR -> lim_inf: 14728.9841 | lim_sup: 20924.2568 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -337.1475 | lim_sup: 561.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -113.1263 | lim_sup: 1307.6038 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -136.2000 | lim_sup: 227.0000 Límites IQR -> lim_inf: -390.4162 | lim_sup: 650.6937 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -261.9800 | lim_sup: 1538.2600 Límites IQR -> lim_inf: 49.3600 | lim_sup: 49.3600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -946.7250 | lim_sup: 1577.8750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -254.2625 | lim_sup: 2258.5375 Límites IQR -> lim_inf: 53.8000 | lim_sup: 53.8000 Límites IQR -> lim_inf: -219.3750 | lim_sup: 365.6250 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -508.8750 | lim_sup: 848.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -497.2663 | lim_sup: 2576.1038 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -105.0000 | lim_sup: 175.0000 Límites IQR -> lim_inf: -1502.1700 | lim_sup: 3344.9500 Límites IQR -> lim_inf: 74.4200 | lim_sup: 74.4200 Límites IQR -> lim_inf: 74.4200 | lim_sup: 74.4200 Límites IQR -> lim_inf: -20.9000 | lim_sup: 252.4000 Límites IQR -> lim_inf: 63.3400 | lim_sup: 63.3400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -15.0000 | lim_sup: 25.0000 Límites IQR -> lim_inf: -204.3750 | lim_sup: 340.6250 Límites IQR -> lim_inf: 55.5350 | lim_sup: 200.7750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -205.6362 | lim_sup: 2659.4137 Límites IQR -> lim_inf: 63.4800 | lim_sup: 63.4800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -793.6350 | lim_sup: 1322.7250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -402.5250 | lim_sup: 2861.2750 Límites IQR -> lim_inf: -11.5216 | lim_sup: 725.6511 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1882.0568 | lim_sup: 4329.4886 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4839.2909 | lim_sup: 5052.7455 Límites IQR -> lim_inf: -383.5227 | lim_sup: 639.2045 Límites IQR -> lim_inf: 10837.6182 | lim_sup: 16536.1818 Límites IQR -> lim_inf: 70.3364 | lim_sup: 210.1182 Límites IQR -> lim_inf: 454.5455 | lim_sup: 454.5455 Límites IQR -> lim_inf: -781.5443 | lim_sup: 1302.5739 Límites IQR -> lim_inf: 990.3273 | lim_sup: 1012.5091 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2163.8898 | lim_sup: 6641.4261 Límites IQR -> lim_inf: -90.2466 | lim_sup: 593.7807 Límites IQR -> lim_inf: 500.0000 | lim_sup: 500.0000 Límites IQR -> lim_inf: -1490.7716 | lim_sup: 4042.0375 Límites IQR -> lim_inf: 4857.6909 | lim_sup: 6371.7273 Límites IQR -> lim_inf: -514.3602 | lim_sup: 857.2670 Límites IQR -> lim_inf: 23130.3398 | lim_sup: 30480.4580 Límites IQR -> lim_inf: -33.2913 | lim_sup: 233.0391 Límites IQR -> lim_inf: -718.3863 | lim_sup: 1784.0059 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -203.8134 | lim_sup: 375.3050 Límites IQR -> lim_inf: 1524.8668 | lim_sup: 9757.3909 Límites IQR -> lim_inf: -158.2797 | lim_sup: 1107.9579 Límites IQR -> lim_inf: 481.3956 | lim_sup: 3502.6966 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -576.0102 | lim_sup: 1060.8170 Límites IQR -> lim_inf: 678.8923 | lim_sup: 21975.3839 Límites IQR -> lim_inf: -47.3087 | lim_sup: 331.1606 Límites IQR -> lim_inf: -261.1628 | lim_sup: 925.8316 Límites IQR -> lim_inf: -117.3223 | lim_sup: 199.9373 Límites IQR -> lim_inf: -16688.1843 | lim_sup: 36930.8978 Límites IQR -> lim_inf: -35.6274 | lim_sup: 249.3922 Límites IQR -> lim_inf: -418.2738 | lim_sup: 1691.8452 Límites IQR -> lim_inf: 21.8218 | lim_sup: 21.8218 Límites IQR -> lim_inf: -109.4932 | lim_sup: 218.8886 Límites IQR -> lim_inf: 719.5326 | lim_sup: 3930.2477 Límites IQR -> lim_inf: -23.3623 | lim_sup: 163.5361 Límites IQR -> lim_inf: -311.5708 | lim_sup: 769.7238 Límites IQR -> lim_inf: -60.4950 | lim_sup: 100.8250 Límites IQR -> lim_inf: 262.7332 | lim_sup: 4374.9385 Límites IQR -> lim_inf: -217.2699 | lim_sup: 362.1165 Límites IQR -> lim_inf: -360.4392 | lim_sup: 833.2920 Límites IQR -> lim_inf: -66.2400 | lim_sup: 110.4000 Límites IQR -> lim_inf: 841.6291 | lim_sup: 4622.6102 Límites IQR -> lim_inf: -157.6953 | lim_sup: 262.8255 Límites IQR -> lim_inf: -381.4738 | lim_sup: 1230.3306 Límites IQR -> lim_inf: 19.4718 | lim_sup: 19.4718 Límites IQR -> lim_inf: -63.7874 | lim_sup: 149.6190 Límites IQR -> lim_inf: 776.2703 | lim_sup: 5358.2934 Límites IQR -> lim_inf: 50.3649 | lim_sup: 140.0585 Límites IQR -> lim_inf: -1138.2285 | lim_sup: 3333.4507 Límites IQR -> lim_inf: -276.4539 | lim_sup: 513.5565 Límites IQR -> lim_inf: 42.3995 | lim_sup: 11437.4144 Límites IQR -> lim_inf: 128.4932 | lim_sup: 128.4932 Límites IQR -> lim_inf: -844.5531 | lim_sup: 2164.3242 Límites IQR -> lim_inf: 540.0000 | lim_sup: 540.0000 Límites IQR -> lim_inf: 4482.9500 | lim_sup: 5048.2300 Límites IQR -> lim_inf: -335.6320 | lim_sup: 761.9947 Límites IQR -> lim_inf: -3916.3894 | lim_sup: 8592.0772 Límites IQR -> lim_inf: -15.7695 | lim_sup: 110.3869 Límites IQR -> lim_inf: -424.9990 | lim_sup: 837.3802 Límites IQR -> lim_inf: -54.7800 | lim_sup: 91.3000 Límites IQR -> lim_inf: -6327.3608 | lim_sup: 13746.4885 Límites IQR -> lim_inf: 272.2647 | lim_sup: 998.3039 Límites IQR -> lim_inf: -1110.7491 | lim_sup: 5542.1039 Límites IQR -> lim_inf: -51.3009 | lim_sup: 708.3003 Límites IQR -> lim_inf: -742.0072 | lim_sup: 1394.0680 Límites IQR -> lim_inf: 3347.0410 | lim_sup: 18768.4620 Límites IQR -> lim_inf: -94.6170 | lim_sup: 157.6950 Límites IQR -> lim_inf: -221.2094 | lim_sup: 521.2239 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -56.4450 | lim_sup: 107.5150 Límites IQR -> lim_inf: -99.1214 | lim_sup: 1713.1180 Límites IQR -> lim_inf: -70.0869 | lim_sup: 116.8115 Límites IQR -> lim_inf: -341.1032 | lim_sup: 775.3864 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: 9.0664 | lim_sup: 9.0664 Límites IQR -> lim_inf: -22.3114 | lim_sup: 51.3126 Límites IQR -> lim_inf: 55.5272 | lim_sup: 1742.0928 Límites IQR -> lim_inf: -186.3538 | lim_sup: 1304.4766 Límites IQR -> lim_inf: -334.4193 | lim_sup: 5377.0956 Límites IQR -> lim_inf: -678.1050 | lim_sup: 1351.4150 Límites IQR -> lim_inf: 4996.9363 | lim_sup: 38972.5667 Límites IQR -> lim_inf: -112.6199 | lim_sup: 788.3397 Límites IQR -> lim_inf: -907.1314 | lim_sup: 5419.2924 Límites IQR -> lim_inf: -400.7044 | lim_sup: 852.1826 Límites IQR -> lim_inf: 1031.1659 | lim_sup: 28946.6712 Límites IQR -> lim_inf: -333.5667 | lim_sup: 555.9445 Límites IQR -> lim_inf: -612.5437 | lim_sup: 1162.0137 Límites IQR -> lim_inf: -97.0500 | lim_sup: 161.7500 Límites IQR -> lim_inf: 129.3809 | lim_sup: 5845.1243 Límites IQR -> lim_inf: 148.2518 | lim_sup: 148.2518 Límites IQR -> lim_inf: -511.7603 | lim_sup: 1424.6309 Límites IQR -> lim_inf: -139.7042 | lim_sup: 264.1993 Límites IQR -> lim_inf: 700.8835 | lim_sup: 8748.4127 Límites IQR -> lim_inf: 332.5078 | lim_sup: 332.5078 Límites IQR -> lim_inf: -1161.4739 | lim_sup: 3013.3435 Límites IQR -> lim_inf: -155.5613 | lim_sup: 318.3544 Límites IQR -> lim_inf: 470.8174 | lim_sup: 11285.0646 Límites IQR -> lim_inf: -127.0734 | lim_sup: 211.7890 Límites IQR -> lim_inf: -880.1862 | lim_sup: 1915.3694 Límites IQR -> lim_inf: -60.1740 | lim_sup: 124.2900 Límites IQR -> lim_inf: 286.5616 | lim_sup: 3863.3696 Límites IQR -> lim_inf: -77.0955 | lim_sup: 128.4925 Límites IQR -> lim_inf: -276.5793 | lim_sup: 645.2618 Límites IQR -> lim_inf: 9.0664 | lim_sup: 9.0664 Límites IQR -> lim_inf: -37.6544 | lim_sup: 71.1200 Límites IQR -> lim_inf: 67.8274 | lim_sup: 1908.7162 Límites IQR -> lim_inf: -127.0731 | lim_sup: 211.7885 Límites IQR -> lim_inf: -265.9262 | lim_sup: 913.6388 Límites IQR -> lim_inf: -52.4550 | lim_sup: 87.4250 Límites IQR -> lim_inf: 749.7281 | lim_sup: 4717.6644 Límites IQR -> lim_inf: -222.3777 | lim_sup: 370.6295 Límites IQR -> lim_inf: -304.2792 | lim_sup: 741.5398 Límites IQR -> lim_inf: -61.5000 | lim_sup: 102.5000 Límites IQR -> lim_inf: 949.0393 | lim_sup: 4414.3407 Límites IQR -> lim_inf: -231.2880 | lim_sup: 385.4800 Límites IQR -> lim_inf: -541.9647 | lim_sup: 1158.1929 Límites IQR -> lim_inf: 16.1146 | lim_sup: 16.1146 Límites IQR -> lim_inf: -90.7163 | lim_sup: 151.1938 Límites IQR -> lim_inf: 827.5626 | lim_sup: 2819.3616 Límites IQR -> lim_inf: -109.0487 | lim_sup: 181.7477 Límites IQR -> lim_inf: -341.9025 | lim_sup: 848.8116 Límites IQR -> lim_inf: 9.0664 | lim_sup: 9.0664 Límites IQR -> lim_inf: -70.7147 | lim_sup: 138.0178 Límites IQR -> lim_inf: -98.3517 | lim_sup: 2488.1572 Límites IQR -> lim_inf: -291.2778 | lim_sup: 485.4630 Límites IQR -> lim_inf: -421.1808 | lim_sup: 1052.6701 Límites IQR -> lim_inf: 16.7860 | lim_sup: 16.7860 Límites IQR -> lim_inf: -154.3500 | lim_sup: 257.2500 Límites IQR -> lim_inf: 660.3736 | lim_sup: 3256.8457 Límites IQR -> lim_inf: -19.0050 | lim_sup: 133.0350 Límites IQR -> lim_inf: -183.8153 | lim_sup: 537.3483 Límites IQR -> lim_inf: -51.1500 | lim_sup: 136.8500 Límites IQR -> lim_inf: 829.5660 | lim_sup: 4243.1468 Límites IQR -> lim_inf: -229.6203 | lim_sup: 534.4755 Límites IQR -> lim_inf: -50.0900 | lim_sup: 130.1500 Límites IQR -> lim_inf: 564.3260 | lim_sup: 2403.7551 Límites IQR -> lim_inf: -58.0000 | lim_sup: 406.0000 Límites IQR -> lim_inf: -638.6961 | lim_sup: 2332.5657 Límites IQR -> lim_inf: -106.7716 | lim_sup: 241.9526 Límites IQR -> lim_inf: 2243.6962 | lim_sup: 13536.4277 Límites IQR -> lim_inf: -20.0050 | lim_sup: 140.0350 Límites IQR -> lim_inf: -335.7836 | lim_sup: 776.2780 Límites IQR -> lim_inf: -45.1500 | lim_sup: 75.2500 Límites IQR -> lim_inf: 701.9466 | lim_sup: 4255.6957 Límites IQR -> lim_inf: -350.3445 | lim_sup: 583.9075 Límites IQR -> lim_inf: -1026.2798 | lim_sup: 1959.0736 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -49.0215 | lim_sup: 81.7025 Límites IQR -> lim_inf: -979.0801 | lim_sup: 6534.4252 Límites IQR -> lim_inf: -72.0000 | lim_sup: 504.0000 Límites IQR -> lim_inf: -240.5427 | lim_sup: 1565.7370 Límites IQR -> lim_inf: -82.7485 | lim_sup: 184.4475 Límites IQR -> lim_inf: 926.0737 | lim_sup: 10537.6305 Límites IQR -> lim_inf: -46.9008 | lim_sup: 328.3056 Límites IQR -> lim_inf: -296.7311 | lim_sup: 989.0867 Límites IQR -> lim_inf: -61.5000 | lim_sup: 102.5000 Límites IQR -> lim_inf: 1297.2673 | lim_sup: 8991.2037 Límites IQR -> lim_inf: -37.8497 | lim_sup: 264.9479 Límites IQR -> lim_inf: -443.3872 | lim_sup: 1038.0243 Límites IQR -> lim_inf: -46.8100 | lim_sup: 101.3500 Límites IQR -> lim_inf: 2018.1117 | lim_sup: 7013.9297 Límites IQR -> lim_inf: -343.6431 | lim_sup: 572.7385 Límites IQR -> lim_inf: -165.2105 | lim_sup: 989.6028 Límites IQR -> lim_inf: -290.0700 | lim_sup: 483.4500 Límites IQR -> lim_inf: 392.1476 | lim_sup: 5565.4242 Límites IQR -> lim_inf: -335.1810 | lim_sup: 558.6350 Límites IQR -> lim_inf: -705.6902 | lim_sup: 1907.9418 Límites IQR -> lim_inf: -95.6755 | lim_sup: 265.5925 Límites IQR -> lim_inf: 1086.8251 | lim_sup: 6543.4780 Límites IQR -> lim_inf: -162.0000 | lim_sup: 270.0000 Límites IQR -> lim_inf: -483.9000 | lim_sup: 1046.4200 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 400.3801 | lim_sup: 4204.8891 Límites IQR -> lim_inf: -173.3781 | lim_sup: 288.9635 Límites IQR -> lim_inf: -345.8750 | lim_sup: 1374.3250 Límites IQR -> lim_inf: -159.6000 | lim_sup: 266.0000 Límites IQR -> lim_inf: 340.3329 | lim_sup: 4949.5072 Límites IQR -> lim_inf: -111.8865 | lim_sup: 186.4775 Límites IQR -> lim_inf: -437.4019 | lim_sup: 951.6571 Límites IQR -> lim_inf: -45.7500 | lim_sup: 76.2500 Límites IQR -> lim_inf: 505.7838 | lim_sup: 2604.0563 Límites IQR -> lim_inf: -135.0600 | lim_sup: 225.1000 Límites IQR -> lim_inf: -407.6751 | lim_sup: 1033.3835 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -106.5180 | lim_sup: 177.5300 Límites IQR -> lim_inf: 389.4878 | lim_sup: 3662.8607 Límites IQR -> lim_inf: -627.0000 | lim_sup: 1045.0000 Límites IQR -> lim_inf: -302.0639 | lim_sup: 2105.6653 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -210.4650 | lim_sup: 403.5750 Límites IQR -> lim_inf: 1166.0206 | lim_sup: 11666.8983 Límites IQR -> lim_inf: 174.0000 | lim_sup: 174.0000 Límites IQR -> lim_inf: -231.5199 | lim_sup: 876.2680 Límites IQR -> lim_inf: -59.3670 | lim_sup: 98.9450 Límites IQR -> lim_inf: 273.8211 | lim_sup: 5180.2838 Límites IQR -> lim_inf: -156.8502 | lim_sup: 261.4170 Límites IQR -> lim_inf: -285.5328 | lim_sup: 545.2214 Límites IQR -> lim_inf: -48.9060 | lim_sup: 81.5100 Límites IQR -> lim_inf: 266.5693 | lim_sup: 1275.3148 Límites IQR -> lim_inf: 168.6800 | lim_sup: 168.6800 Límites IQR -> lim_inf: -481.2080 | lim_sup: 1500.3869 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -178.5500 | lim_sup: 356.2500 Límites IQR -> lim_inf: 1044.6321 | lim_sup: 5760.5788 Límites IQR -> lim_inf: -81.7680 | lim_sup: 572.3756 Límites IQR -> lim_inf: -700.5010 | lim_sup: 1970.4350 Límites IQR -> lim_inf: 20.1214 | lim_sup: 20.1214 Límites IQR -> lim_inf: -91.4810 | lim_sup: 186.0683 Límites IQR -> lim_inf: -957.6424 | lim_sup: 7385.6745 Límites IQR -> lim_inf: -714.6867 | lim_sup: 1191.1445 Límites IQR -> lim_inf: -821.6814 | lim_sup: 2984.1356 Límites IQR -> lim_inf: -571.7395 | lim_sup: 1292.5735 Límites IQR -> lim_inf: 1893.7400 | lim_sup: 13766.8407 Límites IQR -> lim_inf: -234.1854 | lim_sup: 390.3090 Límites IQR -> lim_inf: -496.4801 | lim_sup: 1282.6945 Límites IQR -> lim_inf: -94.3170 | lim_sup: 157.1950 Límites IQR -> lim_inf: 512.7268 | lim_sup: 3499.4292 Límites IQR -> lim_inf: -195.4479 | lim_sup: 325.7465 Límites IQR -> lim_inf: -266.3696 | lim_sup: 1065.4494 Límites IQR -> lim_inf: -77.5125 | lim_sup: 161.1875 Límites IQR -> lim_inf: 1346.0170 | lim_sup: 4130.7403 Límites IQR -> lim_inf: -434.5404 | lim_sup: 724.2340 Límites IQR -> lim_inf: -609.5787 | lim_sup: 2432.9287 Límites IQR -> lim_inf: 728.0000 | lim_sup: 728.0000 Límites IQR -> lim_inf: -42.8675 | lim_sup: 78.9125 Límites IQR -> lim_inf: 593.1029 | lim_sup: 8633.6112 Límites IQR -> lim_inf: -241.4388 | lim_sup: 402.3980 Límites IQR -> lim_inf: -414.5511 | lim_sup: 1166.6647 Límites IQR -> lim_inf: 3570.8524 | lim_sup: 3570.8524 Límites IQR -> lim_inf: -50.1025 | lim_sup: 109.3175 Límites IQR -> lim_inf: 1386.3636 | lim_sup: 5258.7166 Límites IQR -> lim_inf: -26.9857 | lim_sup: 188.8996 Límites IQR -> lim_inf: -868.1122 | lim_sup: 2129.5316 Límites IQR -> lim_inf: -58.3525 | lim_sup: 131.7075 Límites IQR -> lim_inf: 831.6707 | lim_sup: 5503.3348 Límites IQR -> lim_inf: -149.0352 | lim_sup: 248.3920 Límites IQR -> lim_inf: -409.2908 | lim_sup: 906.7342 Límites IQR -> lim_inf: -47.8455 | lim_sup: 79.7425 Límites IQR -> lim_inf: 537.7572 | lim_sup: 3102.0457 Límites IQR -> lim_inf: -194.9445 | lim_sup: 324.9075 Límites IQR -> lim_inf: -241.7390 | lim_sup: 752.1823 Límites IQR -> lim_inf: -155.0332 | lim_sup: 258.3886 Límites IQR -> lim_inf: 661.1363 | lim_sup: 3620.4024 Límites IQR -> lim_inf: 186.2136 | lim_sup: 186.2136 Límites IQR -> lim_inf: -533.4899 | lim_sup: 1082.0439 Límites IQR -> lim_inf: -94.5360 | lim_sup: 157.5600 Límites IQR -> lim_inf: 476.4825 | lim_sup: 2442.2941 Límites IQR -> lim_inf: -487.2789 | lim_sup: 812.1315 Límites IQR -> lim_inf: -1052.0436 | lim_sup: 5848.1686 Límites IQR -> lim_inf: 3651.8672 | lim_sup: 3651.8672 Límites IQR -> lim_inf: -260.3801 | lim_sup: 1689.9668 Límites IQR -> lim_inf: -4876.2243 | lim_sup: 17467.9138 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -143.6778 | lim_sup: 1005.7442 Límites IQR -> lim_inf: -1169.3594 | lim_sup: 4922.8686 Límites IQR -> lim_inf: -396.2008 | lim_sup: 738.3614 Límites IQR -> lim_inf: -253.1984 | lim_sup: 13458.3776 Límites IQR -> lim_inf: -73.5913 | lim_sup: 515.1391 Límites IQR -> lim_inf: -1185.0446 | lim_sup: 4522.1446 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -340.2436 | lim_sup: 934.7706 Límites IQR -> lim_inf: 2884.6029 | lim_sup: 17479.8962 Límites IQR -> lim_inf: -465.0300 | lim_sup: 775.0500 Límites IQR -> lim_inf: 379.9358 | lim_sup: 2861.1980 Límites IQR -> lim_inf: -465.7900 | lim_sup: 826.9700 Límites IQR -> lim_inf: 584.5825 | lim_sup: 19484.8433 Límites IQR -> lim_inf: -336.5400 | lim_sup: 560.9000 Límites IQR -> lim_inf: -35.1786 | lim_sup: 58.6310 Límites IQR -> lim_inf: 104.3332 | lim_sup: 586.9484 Límites IQR -> lim_inf: -32.0100 | lim_sup: 224.0700 Límites IQR -> lim_inf: -459.8602 | lim_sup: 1194.6662 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -81.4000 | lim_sup: 189.0000 Límites IQR -> lim_inf: 73.7798 | lim_sup: 7146.1719 Límites IQR -> lim_inf: -570.6009 | lim_sup: 951.0015 Límites IQR -> lim_inf: -887.4266 | lim_sup: 4641.7528 Límites IQR -> lim_inf: 600.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: -252.6766 | lim_sup: 702.9282 Límites IQR -> lim_inf: 2968.0838 | lim_sup: 13410.0592 Límites IQR -> lim_inf: -495.0000 | lim_sup: 825.0000 Límites IQR -> lim_inf: -580.7972 | lim_sup: 3940.9784 Límites IQR -> lim_inf: -183.0685 | lim_sup: 352.0475 Límites IQR -> lim_inf: 689.1187 | lim_sup: 18632.4870 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -558.8550 | lim_sup: 931.4250 Límites IQR -> lim_inf: -220.5750 | lim_sup: 786.5050 Límites IQR -> lim_inf: -46.2375 | lim_sup: 77.0625 Límites IQR -> lim_inf: -194.6587 | lim_sup: 1164.9913 Límites IQR -> lim_inf: 37.6900 | lim_sup: 37.6900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -121.1475 | lim_sup: 201.9125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: 2.2512 | lim_sup: 1529.2812 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 122.4100 | lim_sup: 122.4100 Límites IQR -> lim_inf: 728.2700 | lim_sup: 728.2700 Límites IQR -> lim_inf: 963.0000 | lim_sup: 963.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 885.9050 | lim_sup: 1521.8650 Límites IQR -> lim_inf: -1133.6600 | lim_sup: 9278.6400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -139.2250 | lim_sup: 525.3750 Límites IQR -> lim_inf: -1264.8750 | lim_sup: 2108.1250 Límites IQR -> lim_inf: -836.0725 | lim_sup: 35642.0675 Límites IQR -> lim_inf: 580.8900 | lim_sup: 580.8900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.4375 | lim_sup: 194.0625 Límites IQR -> lim_inf: -3.4650 | lim_sup: 545.7750 Límites IQR -> lim_inf: -218.6400 | lim_sup: 364.4000 Límites IQR -> lim_inf: -78.3075 | lim_sup: 395.7925 Límites IQR -> lim_inf: 623.0000 | lim_sup: 623.0000 Límites IQR -> lim_inf: -499.7250 | lim_sup: 832.8750 Límites IQR -> lim_inf: -1269.8825 | lim_sup: 5284.3375 Límites IQR -> lim_inf: -1322.4775 | lim_sup: 4629.3425 Límites IQR -> lim_inf: 3967.5900 | lim_sup: 5626.2300 Límites IQR -> lim_inf: -714.4725 | lim_sup: 1190.7875 Límites IQR -> lim_inf: 291.1325 | lim_sup: 20970.4125 Límites IQR -> lim_inf: -51.8909 | lim_sup: 683.6000 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -1080.2193 | lim_sup: 2780.5716 Límites IQR -> lim_inf: 3778.4432 | lim_sup: 6773.5523 Límites IQR -> lim_inf: -92.0455 | lim_sup: 153.4091 Límites IQR -> lim_inf: 7109.1352 | lim_sup: 12021.1625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -218.0400 | lim_sup: 363.4000 Límites IQR -> lim_inf: 240.0000 | lim_sup: 240.0000 Límites IQR -> lim_inf: -32.6850 | lim_sup: 54.4750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -84.8600 | lim_sup: 1005.2200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -320.4050 | lim_sup: 719.5950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 128.0000 | lim_sup: 128.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -196.8750 | lim_sup: 328.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -264.1650 | lim_sup: 440.2750 Límites IQR -> lim_inf: -236.0250 | lim_sup: 393.3750 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -326.7325 | lim_sup: 752.5875 Límites IQR -> lim_inf: 57.5000 | lim_sup: 141.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -38.2137 | lim_sup: 1273.0963 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -236.9700 | lim_sup: 394.9500 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -91.0725 | lim_sup: 151.7875 Límites IQR -> lim_inf: -174.2562 | lim_sup: 1372.7737 Límites IQR -> lim_inf: -135.0000 | lim_sup: 405.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -310.3162 | lim_sup: 517.1937 Límites IQR -> lim_inf: 83.7500 | lim_sup: 125.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 32.3838 | lim_sup: 1155.9938 Límites IQR -> lim_inf: 125.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -299.2500 | lim_sup: 498.7500 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -137.8775 | lim_sup: 639.2425 Límites IQR -> lim_inf: -201.1994 | lim_sup: 1408.3954 Límites IQR -> lim_inf: -384.8433 | lim_sup: 3164.3068 Límites IQR -> lim_inf: -264.0350 | lim_sup: 535.7650 Límites IQR -> lim_inf: 4381.5471 | lim_sup: 27170.4688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -393.2250 | lim_sup: 655.3750 Límites IQR -> lim_inf: -2219.4288 | lim_sup: 7252.3813 Límites IQR -> lim_inf: 192.4700 | lim_sup: 192.4700 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: -157.8650 | lim_sup: 519.3950 Límites IQR -> lim_inf: 14.4688 | lim_sup: 207.8787 Límites IQR -> lim_inf: -362.7675 | lim_sup: 1731.6125 Límites IQR -> lim_inf: -281.2500 | lim_sup: 468.7500 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: -525.4987 | lim_sup: 875.8312 Límites IQR -> lim_inf: -37.5000 | lim_sup: 62.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -65.6375 | lim_sup: 1738.7825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1443.0000 | lim_sup: 2405.0000 Límites IQR -> lim_inf: -1301.8663 | lim_sup: 3220.1237 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4161.2000 | lim_sup: 5175.1200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -498.0763 | lim_sup: 8530.2938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 73.3163 | lim_sup: 744.4262 Límites IQR -> lim_inf: -1445.5875 | lim_sup: 3643.7925 Límites IQR -> lim_inf: 6655.3763 | lim_sup: 8774.5863 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -1261.6275 | lim_sup: 13266.7325 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -262.5000 | lim_sup: 437.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 490.9900 | lim_sup: 490.9900 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -64.4288 | lim_sup: 483.2613 Límites IQR -> lim_inf: -143.2463 | lim_sup: 595.9038 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: -768.5512 | lim_sup: 1365.8387 Límites IQR -> lim_inf: -77.2500 | lim_sup: 128.7500 Límites IQR -> lim_inf: -182.1450 | lim_sup: 303.5750 Límites IQR -> lim_inf: -259.1050 | lim_sup: 2376.8150 Límites IQR -> lim_inf: -209.8300 | lim_sup: 2542.4500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -33.7500 | lim_sup: 56.2500 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: 1347.6300 | lim_sup: 1347.6300 Límites IQR -> lim_inf: -70.9287 | lim_sup: 1752.0212 Límites IQR -> lim_inf: 133.8500 | lim_sup: 133.8500 Límites IQR -> lim_inf: -1043.6250 | lim_sup: 1739.3750 Límites IQR -> lim_inf: 149.3000 | lim_sup: 170.4200 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: 42.9725 | lim_sup: 255.9925 Límites IQR -> lim_inf: -67.2550 | lim_sup: 200.2650 Límites IQR -> lim_inf: -827.4150 | lim_sup: 2886.0850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -130.2600 | lim_sup: 217.1000 Límites IQR -> lim_inf: -242.0850 | lim_sup: 403.4750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 134.0950 | lim_sup: 1109.5950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -232.5525 | lim_sup: 387.5875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -162.7887 | lim_sup: 1714.5212 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -585.0000 | lim_sup: 975.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 41.2850 | lim_sup: 148.1450 Límites IQR -> lim_inf: -171.2050 | lim_sup: 2151.8750 Límites IQR -> lim_inf: 8.6475 | lim_sup: 1490.2075 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -967.5450 | lim_sup: 1612.5750 Límites IQR -> lim_inf: 222.4500 | lim_sup: 222.4500 Límites IQR -> lim_inf: -71.3000 | lim_sup: 628.4600 Límites IQR -> lim_inf: -43.0813 | lim_sup: 2124.8288 Límites IQR -> lim_inf: -151.8775 | lim_sup: 1485.8025 Límites IQR -> lim_inf: -318.7937 | lim_sup: 797.9895 Límites IQR -> lim_inf: -461.3539 | lim_sup: 1198.1499 Límites IQR -> lim_inf: -522.4154 | lim_sup: 870.6923 Límites IQR -> lim_inf: 35.2946 | lim_sup: 649.7694 Límites IQR -> lim_inf: -638.4982 | lim_sup: 3287.5965 Límites IQR -> lim_inf: -1637.9151 | lim_sup: 3007.7281 Límites IQR -> lim_inf: -67.0829 | lim_sup: 2872.2447 Límites IQR -> lim_inf: 424.2097 | lim_sup: 1187.0656 Límites IQR -> lim_inf: 545.7771 | lim_sup: 2443.9108 Límites IQR -> lim_inf: -22106.9368 | lim_sup: 42355.7637 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 7215.8293 | lim_sup: 13263.5111 Límites IQR -> lim_inf: -1225.6041 | lim_sup: 20899.9174 Límites IQR -> lim_inf: 8135.5775 | lim_sup: 37699.6975 Límites IQR -> lim_inf: -4675.0834 | lim_sup: 12358.7371 Límites IQR -> lim_inf: 23881.0866 | lim_sup: 36322.8821 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1496.0625 | lim_sup: 2972.6265 Límites IQR -> lim_inf: -1154.3155 | lim_sup: 4569.4105 Límites IQR -> lim_inf: 1734.9315 | lim_sup: 13118.5635 Límites IQR -> lim_inf: -789.5250 | lim_sup: 1315.8750 Límites IQR -> lim_inf: 6858.9603 | lim_sup: 16588.5708 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2020.5182 | lim_sup: 4054.6723 Límites IQR -> lim_inf: -2130.3115 | lim_sup: 7046.3825 Límites IQR -> lim_inf: 1213.8072 | lim_sup: 11199.0292 Límites IQR -> lim_inf: -446.1937 | lim_sup: 743.6562 Límites IQR -> lim_inf: -34.1898 | lim_sup: 20933.2217 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1393.0722 | lim_sup: 2588.9743 Límites IQR -> lim_inf: -1289.2332 | lim_sup: 4440.0993 Límites IQR -> lim_inf: 887.1992 | lim_sup: 12994.9533 Límites IQR -> lim_inf: -439.2000 | lim_sup: 765.6000 Límites IQR -> lim_inf: 4698.4569 | lim_sup: 11411.6159 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1308.2203 | lim_sup: 2585.3102 Límites IQR -> lim_inf: -586.7794 | lim_sup: 2728.2186 Límites IQR -> lim_inf: -455.5115 | lim_sup: 9462.9005 Límites IQR -> lim_inf: -851.9474 | lim_sup: 2072.4136 Límites IQR -> lim_inf: 2625.4943 | lim_sup: 7437.9418 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1611.2515 | lim_sup: 3210.3595 Límites IQR -> lim_inf: -914.5504 | lim_sup: 4157.1136 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 138.4235 | lim_sup: 14203.0075 Límites IQR -> lim_inf: -2580.7500 | lim_sup: 5133.4900 Límites IQR -> lim_inf: 3496.8356 | lim_sup: 8313.3641 Límites IQR -> lim_inf: -29.9337 | lim_sup: 89.8012 Límites IQR -> lim_inf: 1946.9631 | lim_sup: 2526.5140 Límites IQR -> lim_inf: -1257.7777 | lim_sup: 2096.2962 Límites IQR -> lim_inf: 1498.1733 | lim_sup: 5755.3952 Límites IQR -> lim_inf: -300.0000 | lim_sup: 500.0000 Límites IQR -> lim_inf: -9001.4993 | lim_sup: 18290.2808 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1218.8029 | lim_sup: 2323.8524 Límites IQR -> lim_inf: -460.8044 | lim_sup: 2592.4976 Límites IQR -> lim_inf: 586.9685 | lim_sup: 9218.8245 Límites IQR -> lim_inf: -300.0000 | lim_sup: 500.0000 Límites IQR -> lim_inf: 6383.9218 | lim_sup: 14749.5357 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3767.4883 | lim_sup: 7093.0094 Límites IQR -> lim_inf: -204.5977 | lim_sup: 4867.6628 Límites IQR -> lim_inf: 2831.1945 | lim_sup: 18016.9265 Límites IQR -> lim_inf: 95.1500 | lim_sup: 814.7500 Límites IQR -> lim_inf: 14399.3074 | lim_sup: 28060.3544 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2237.5606 | lim_sup: 4328.3902 Límites IQR -> lim_inf: 1371.4962 | lim_sup: 3338.4372 Límites IQR -> lim_inf: 1173.4317 | lim_sup: 12481.7298 Límites IQR -> lim_inf: -956.6250 | lim_sup: 1594.3750 Límites IQR -> lim_inf: 10277.0139 | lim_sup: 18535.5399 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -610.2143 | lim_sup: 1017.0238 Límites IQR -> lim_inf: 678.5714 | lim_sup: 678.5714 Límites IQR -> lim_inf: 46.3595 | lim_sup: 81.4643 Límites IQR -> lim_inf: -166.0714 | lim_sup: 276.7857 Límites IQR -> lim_inf: 1299.4101 | lim_sup: 2803.4958 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -840.3271 | lim_sup: 1932.0896 Límites IQR -> lim_inf: -122.0286 | lim_sup: 203.3810 Límites IQR -> lim_inf: -84.0976 | lim_sup: 219.2738 Límites IQR -> lim_inf: 1354.5000 | lim_sup: 9111.0381 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -532.1429 | lim_sup: 886.9048 Límites IQR -> lim_inf: -871.9164 | lim_sup: 2492.9336 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -273.8298 | lim_sup: 596.5798 Límites IQR -> lim_inf: -143.7500 | lim_sup: 239.5833 Límites IQR -> lim_inf: 3274.3714 | lim_sup: 8895.5810 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1545.3795 | lim_sup: 3007.2435 Límites IQR -> lim_inf: 283.6354 | lim_sup: 1798.0424 Límites IQR -> lim_inf: 1131.2415 | lim_sup: 7206.9895 Límites IQR -> lim_inf: -477.0000 | lim_sup: 795.0000 Límites IQR -> lim_inf: 4338.0351 | lim_sup: 10026.5596 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1077.9196 | lim_sup: 1842.9423 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -11.6488 | lim_sup: 165.4464 Límites IQR -> lim_inf: -24.2134 | lim_sup: 40.3557 Límites IQR -> lim_inf: 1961.5929 | lim_sup: 4743.4500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -194.7467 | lim_sup: 502.9509 Límites IQR -> lim_inf: -93.0000 | lim_sup: 155.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1148.9101 | lim_sup: 3365.2292 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -978.0560 | lim_sup: 1793.5345 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -148.5536 | lim_sup: 247.5893 Límites IQR -> lim_inf: -9.4446 | lim_sup: 15.7411 Límites IQR -> lim_inf: 1984.2527 | lim_sup: 3913.0503 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 951.1523 | lim_sup: 3272.6982 Límites IQR -> lim_inf: -1060.7377 | lim_sup: 2822.5445 Límites IQR -> lim_inf: 809.0523 | lim_sup: 9344.9462 Límites IQR -> lim_inf: -1151.8069 | lim_sup: 2392.4781 Límites IQR -> lim_inf: 5165.2600 | lim_sup: 9315.9340 Límites IQR -> lim_inf: -250.0000 | lim_sup: 416.6667 Límites IQR -> lim_inf: -396.9643 | lim_sup: 1175.8929 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 99.0357 | lim_sup: 99.0357 Límites IQR -> lim_inf: -8.1857 | lim_sup: 13.6429 Límites IQR -> lim_inf: 3941.0583 | lim_sup: 8546.7964 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1935.4921 | lim_sup: 3641.3843 Límites IQR -> lim_inf: -1714.2721 | lim_sup: 4953.9329 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1233.7462 | lim_sup: 10734.8603 Límites IQR -> lim_inf: -418.7160 | lim_sup: 697.8600 Límites IQR -> lim_inf: 7810.8384 | lim_sup: 13905.7674 Límites IQR -> lim_inf: -646.4286 | lim_sup: 1077.3810 Límites IQR -> lim_inf: -952.4155 | lim_sup: 4285.6607 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -145.3488 | lim_sup: 448.3560 Límites IQR -> lim_inf: -127.8006 | lim_sup: 301.8899 Límites IQR -> lim_inf: 4490.6199 | lim_sup: 14753.3842 Límites IQR -> lim_inf: -589.2857 | lim_sup: 982.1429 Límites IQR -> lim_inf: -636.9702 | lim_sup: 3856.5107 Límites IQR -> lim_inf: 53.9143 | lim_sup: 198.0476 Límites IQR -> lim_inf: -289.8199 | lim_sup: 623.4205 Límites IQR -> lim_inf: 1137.4872 | lim_sup: 9710.1753 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1635.5222 | lim_sup: 3297.9243 Límites IQR -> lim_inf: -1396.4631 | lim_sup: 4025.0964 Límites IQR -> lim_inf: 505.7418 | lim_sup: 5088.0597 Límites IQR -> lim_inf: -565.9875 | lim_sup: 943.3125 Límites IQR -> lim_inf: 1894.0085 | lim_sup: 13474.9085 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1778.8872 | lim_sup: 4413.6493 Límites IQR -> lim_inf: -1765.1070 | lim_sup: 6316.4710 Límites IQR -> lim_inf: 1205.1735 | lim_sup: 7301.9055 Límites IQR -> lim_inf: -493.6125 | lim_sup: 822.6875 Límites IQR -> lim_inf: 1100.8856 | lim_sup: 18134.4131 Límites IQR -> lim_inf: -233.4150 | lim_sup: 389.0250 Límites IQR -> lim_inf: 4884.3655 | lim_sup: 9538.3473 Límites IQR -> lim_inf: -1022.8106 | lim_sup: 10812.3787 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 8363.5725 | lim_sup: 18420.4405 Límites IQR -> lim_inf: -1245.8925 | lim_sup: 2076.4875 Límites IQR -> lim_inf: 16654.8824 | lim_sup: 40057.2029 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1609.4072 | lim_sup: 3017.4453 Límites IQR -> lim_inf: -1748.9474 | lim_sup: 4768.3411 Límites IQR -> lim_inf: 1258.3353 | lim_sup: 11832.0652 Límites IQR -> lim_inf: -736.5000 | lim_sup: 1227.5000 Límites IQR -> lim_inf: 6346.6662 | lim_sup: 15566.6483 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -31.2612 | lim_sup: 197.5167 Límites IQR -> lim_inf: -952.6012 | lim_sup: 1587.6687 Límites IQR -> lim_inf: 297.9223 | lim_sup: 1414.8722 Límites IQR -> lim_inf: 172.0000 | lim_sup: 172.0000 Límites IQR -> lim_inf: 459.2051 | lim_sup: 710.5691 Límites IQR -> lim_inf: -154.5673 | lim_sup: 289.6635 Límites IQR -> lim_inf: -178.8658 | lim_sup: 829.2407 Límites IQR -> lim_inf: 264.2577 | lim_sup: 341.5192 Límites IQR -> lim_inf: 66.5180 | lim_sup: 747.6542 Límites IQR -> lim_inf: -556.7966 | lim_sup: 7593.7100 Límites IQR -> lim_inf: -28.8462 | lim_sup: 248.0769 Límites IQR -> lim_inf: -729.2069 | lim_sup: 2412.2394 Límites IQR -> lim_inf: 383.3538 | lim_sup: 474.9231 Límites IQR -> lim_inf: 324.4446 | lim_sup: 1373.9458 Límites IQR -> lim_inf: -1742.8088 | lim_sup: 21296.3054 Límites IQR -> lim_inf: 62.5000 | lim_sup: 162.5000 Límites IQR -> lim_inf: -397.6913 | lim_sup: 662.8189 Límites IQR -> lim_inf: -156.2308 | lim_sup: 260.3846 Límites IQR -> lim_inf: -169.1215 | lim_sup: 281.8692 Límites IQR -> lim_inf: 427.5192 | lim_sup: 2339.3164 Límites IQR -> lim_inf: -392.8500 | lim_sup: 654.7500 Límites IQR -> lim_inf: -369.5280 | lim_sup: 773.8866 Límites IQR -> lim_inf: 208.9492 | lim_sup: 299.5462 Límites IQR -> lim_inf: -122.5904 | lim_sup: 490.8143 Límites IQR -> lim_inf: -936.9137 | lim_sup: 8912.2846 Límites IQR -> lim_inf: -621.2981 | lim_sup: 1609.8558 Límites IQR -> lim_inf: -765.6118 | lim_sup: 2111.4789 Límites IQR -> lim_inf: 486.0077 | lim_sup: 606.1923 Límites IQR -> lim_inf: 367.9668 | lim_sup: 1949.2740 Límites IQR -> lim_inf: -1087.5495 | lim_sup: 25144.6128 Límites IQR -> lim_inf: -86.5385 | lim_sup: 144.2308 Límites IQR -> lim_inf: -346.3145 | lim_sup: 961.9156 Límites IQR -> lim_inf: 335.0308 | lim_sup: 415.1538 Límites IQR -> lim_inf: 92.8057 | lim_sup: 869.1534 Límites IQR -> lim_inf: 2018.0516 | lim_sup: 7046.8290 Límites IQR -> lim_inf: -165.4196 | lim_sup: 521.8531 Límites IQR -> lim_inf: -696.6983 | lim_sup: 16389.2792 Límites IQR -> lim_inf: 5132.7723 | lim_sup: 18518.4942 Límites IQR -> lim_inf: -665.2702 | lim_sup: 1108.7837 Límites IQR -> lim_inf: 20846.3565 | lim_sup: 43239.3970 Límites IQR -> lim_inf: -180.0000 | lim_sup: 300.0000 Límites IQR -> lim_inf: 1851.2023 | lim_sup: 3713.8962 Límites IQR -> lim_inf: -1935.5948 | lim_sup: 7564.4409 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -2374.4112 | lim_sup: 16437.1908 Límites IQR -> lim_inf: -1265.7606 | lim_sup: 2109.6009 Límites IQR -> lim_inf: 7816.6240 | lim_sup: 18670.4200 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -1442.1884 | lim_sup: 4110.8267 Límites IQR -> lim_inf: 2398.7115 | lim_sup: 4794.9885 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2776.0965 | lim_sup: 11316.9181 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -1918.8150 | lim_sup: 6024.9268 Límites IQR -> lim_inf: 6627.5923 | lim_sup: 11541.5000 Límites IQR -> lim_inf: -24.1087 | lim_sup: 40.1812 Límites IQR -> lim_inf: 14040.8347 | lim_sup: 28314.8155 Límites IQR -> lim_inf: 74.1600 | lim_sup: 74.1600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -95.8350 | lim_sup: 159.7250 Límites IQR -> lim_inf: -465.4050 | lim_sup: 775.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -615.0750 | lim_sup: 3689.3850 Límites IQR -> lim_inf: 54.8600 | lim_sup: 54.8600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -140.5688 | lim_sup: 234.2813 Límites IQR -> lim_inf: -336.9412 | lim_sup: 561.5687 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -906.3775 | lim_sup: 3535.5025 Límites IQR -> lim_inf: 54.8600 | lim_sup: 54.8600 Límites IQR -> lim_inf: 221.0500 | lim_sup: 221.0500 Límites IQR -> lim_inf: -161.3775 | lim_sup: 268.9625 Límites IQR -> lim_inf: -209.7000 | lim_sup: 349.5000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -45.5550 | lim_sup: 75.9250 Límites IQR -> lim_inf: -319.0263 | lim_sup: 2236.3238 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -364.2262 | lim_sup: 607.0438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 31.0925 | lim_sup: 1356.8325 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -130.2600 | lim_sup: 217.1000 Límites IQR -> lim_inf: -379.8750 | lim_sup: 633.1250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -217.2337 | lim_sup: 362.0562 Límites IQR -> lim_inf: -294.4738 | lim_sup: 2620.9563 Límites IQR -> lim_inf: 46.1538 | lim_sup: 46.1538 Límites IQR -> lim_inf: -265.3846 | lim_sup: 442.3077 Límites IQR -> lim_inf: 276.9231 | lim_sup: 523.0769 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -653.6159 | lim_sup: 1585.8079 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -942.9461 | lim_sup: 2111.7199 Límites IQR -> lim_inf: 4643.4385 | lim_sup: 7180.3615 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4581.4446 | lim_sup: 8809.2348 Límites IQR -> lim_inf: -101.1189 | lim_sup: 291.6084 Límites IQR -> lim_inf: -1007.0168 | lim_sup: 3937.0039 Límites IQR -> lim_inf: 2603.9096 | lim_sup: 5174.7619 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2879.3087 | lim_sup: 10297.7595 Límites IQR -> lim_inf: 53.5700 | lim_sup: 53.5700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -682.5000 | lim_sup: 1137.5000 Límites IQR -> lim_inf: -501.6000 | lim_sup: 879.8000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 117.0938 | lim_sup: 2036.7837 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -562.2938 | lim_sup: 937.1562 Límites IQR -> lim_inf: -192.0000 | lim_sup: 320.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 111.8962 | lim_sup: 1548.3063 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -253.4663 | lim_sup: 422.4438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -471.9325 | lim_sup: 2092.1675 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -181.3200 | lim_sup: 302.2000 Límites IQR -> lim_inf: -438.0000 | lim_sup: 730.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 189.4300 | lim_sup: 189.4300 Límites IQR -> lim_inf: -142.7800 | lim_sup: 1746.5400 Límites IQR -> lim_inf: 66.4600 | lim_sup: 66.4600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -303.3000 | lim_sup: 505.5000 Límites IQR -> lim_inf: -474.8025 | lim_sup: 791.3375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -345.0375 | lim_sup: 2851.9825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -657.2110 | lim_sup: 1521.0057 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -175.2393 | lim_sup: 292.0655 Límites IQR -> lim_inf: -145.3598 | lim_sup: 242.2664 Límites IQR -> lim_inf: 4329.0557 | lim_sup: 9224.7152 Límites IQR -> lim_inf: -385.4799 | lim_sup: 642.4665 Límites IQR -> lim_inf: -629.1030 | lim_sup: 1705.9232 Límites IQR -> lim_inf: 182.8480 | lim_sup: 182.8480 Límites IQR -> lim_inf: -123.0686 | lim_sup: 333.7810 Límites IQR -> lim_inf: 549.1928 | lim_sup: 7759.6426 Límites IQR -> lim_inf: 48.3100 | lim_sup: 48.3100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -411.3150 | lim_sup: 975.5250 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -59.9400 | lim_sup: 2004.4800 Límites IQR -> lim_inf: 69.2800 | lim_sup: 69.2800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -231.0225 | lim_sup: 385.0375 Límites IQR -> lim_inf: -456.0000 | lim_sup: 928.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 70.0150 | lim_sup: 2726.0550 Límites IQR -> lim_inf: -538.9209 | lim_sup: 898.2015 Límites IQR -> lim_inf: -319.7062 | lim_sup: 1604.1914 Límites IQR -> lim_inf: -268.9053 | lim_sup: 448.1755 Límites IQR -> lim_inf: 684.6996 | lim_sup: 10722.3682 Límites IQR -> lim_inf: 58.4300 | lim_sup: 58.4300 Límites IQR -> lim_inf: 290.0000 | lim_sup: 290.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -562.9050 | lim_sup: 938.1750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 110.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -395.2387 | lim_sup: 2811.2712 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -154.3200 | lim_sup: 257.2000 Límites IQR -> lim_inf: -360.0000 | lim_sup: 600.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -182.1450 | lim_sup: 303.5750 Límites IQR -> lim_inf: -324.1238 | lim_sup: 2328.0663 Límites IQR -> lim_inf: 220.3700 | lim_sup: 220.3700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1633.5000 | lim_sup: 2722.5000 Límites IQR -> lim_inf: -849.2250 | lim_sup: 4244.9950 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1079.6975 | lim_sup: 1887.5575 Límites IQR -> lim_inf: -2812.2000 | lim_sup: 4687.0000 Límites IQR -> lim_inf: 20.2162 | lim_sup: 8347.7863 Límites IQR -> lim_inf: -43.2202 | lim_sup: 302.5414 Límites IQR -> lim_inf: -412.8996 | lim_sup: 1863.4498 Límites IQR -> lim_inf: 21.5532 | lim_sup: 21.5532 Límites IQR -> lim_inf: -225.6840 | lim_sup: 376.1400 Límites IQR -> lim_inf: 1274.7601 | lim_sup: 8867.3855 Límites IQR -> lim_inf: -7.2237 | lim_sup: 97.8462 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 8.5325 | lim_sup: 49.6125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -171.7977 | lim_sup: 999.2122 Límites IQR -> lim_inf: -871.0388 | lim_sup: 4058.7227 Límites IQR -> lim_inf: 2539.6867 | lim_sup: 10814.6768 Límites IQR -> lim_inf: -506.2500 | lim_sup: 843.7500 Límites IQR -> lim_inf: 10973.3791 | lim_sup: 23192.5846 Límites IQR -> lim_inf: 128.7807 | lim_sup: 467.8625 Límites IQR -> lim_inf: -271.9318 | lim_sup: 961.7045 Límites IQR -> lim_inf: -1887.0784 | lim_sup: 5287.7125 Límites IQR -> lim_inf: 4800.0739 | lim_sup: 5403.3375 Límites IQR -> lim_inf: -600.0000 | lim_sup: 1000.0000 Límites IQR -> lim_inf: 10248.5932 | lim_sup: 12804.7750 Límites IQR -> lim_inf: 53.2100 | lim_sup: 53.2100 Límites IQR -> lim_inf: 530.0000 | lim_sup: 530.0000 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -670.8387 | lim_sup: 1488.5113 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -177.0025 | lim_sup: 2273.3375 Límites IQR -> lim_inf: 86.0955 | lim_sup: 453.6227 Límites IQR -> lim_inf: 181.8182 | lim_sup: 181.8182 Límites IQR -> lim_inf: -374.2523 | lim_sup: 4748.6023 Límites IQR -> lim_inf: -7448.7107 | lim_sup: 17031.9420 Límites IQR -> lim_inf: -234.5455 | lim_sup: 390.9091 Límites IQR -> lim_inf: 4884.2307 | lim_sup: 7058.4761 Límites IQR -> lim_inf: 28.9625 | lim_sup: 392.3352 Límites IQR -> lim_inf: 49.0909 | lim_sup: 100.0000 Límites IQR -> lim_inf: -2042.7284 | lim_sup: 6577.0080 Límites IQR -> lim_inf: 3060.7409 | lim_sup: 7911.5409 Límites IQR -> lim_inf: -329.2364 | lim_sup: 548.7273 Límites IQR -> lim_inf: 8498.6830 | lim_sup: 10799.4375 Límites IQR -> lim_inf: 280.0000 | lim_sup: 280.0000 Límites IQR -> lim_inf: -60.0000 | lim_sup: 100.0000 Límites IQR -> lim_inf: -335.9100 | lim_sup: 559.8500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 195.8675 | lim_sup: 2160.2875 Límites IQR -> lim_inf: 45.4800 | lim_sup: 45.4800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -286.5000 | lim_sup: 477.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -257.4212 | lim_sup: 2132.1888 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1922.0888 | lim_sup: 3912.1507 Límites IQR -> lim_inf: -4210.4742 | lim_sup: 9257.6313 Límites IQR -> lim_inf: 1879.3740 | lim_sup: 12389.9300 Límites IQR -> lim_inf: -309.3750 | lim_sup: 515.6250 Límites IQR -> lim_inf: 7752.1877 | lim_sup: 19190.7218 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -93.7500 | lim_sup: 156.2500 Límites IQR -> lim_inf: -756.2699 | lim_sup: 2374.6658 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -4574.4750 | lim_sup: 7624.1250 Límites IQR -> lim_inf: -244.4518 | lim_sup: 407.4196 Límites IQR -> lim_inf: 571.4235 | lim_sup: 1240.3640 Límites IQR -> lim_inf: 195.1864 | lim_sup: 357.1864 Límites IQR -> lim_inf: -209.0909 | lim_sup: 634.5455 Límites IQR -> lim_inf: -2038.7875 | lim_sup: 4464.1670 Límites IQR -> lim_inf: 2303.7909 | lim_sup: 6463.7182 Límites IQR -> lim_inf: -535.8068 | lim_sup: 893.0114 Límites IQR -> lim_inf: 7956.1943 | lim_sup: 10095.0761 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 293.8500 | lim_sup: 293.8500 Límites IQR -> lim_inf: 868.6400 | lim_sup: 868.6400 Límites IQR -> lim_inf: -74.9350 | lim_sup: 343.6650 Límites IQR -> lim_inf: 40.4500 | lim_sup: 40.4500 Límites IQR -> lim_inf: 280.0000 | lim_sup: 280.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -468.4613 | lim_sup: 780.7687 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -106.0413 | lim_sup: 1998.0688 Límites IQR -> lim_inf: -608.8138 | lim_sup: 2654.7762 Límites IQR -> lim_inf: -668.2600 | lim_sup: 1451.3400 Límites IQR -> lim_inf: -463.0600 | lim_sup: 3456.9400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -238.3825 | lim_sup: 2650.6375 Límites IQR -> lim_inf: -512.1900 | lim_sup: 2390.5100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -82.5000 | lim_sup: 137.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1753.3437 | lim_sup: 10232.9263 Límites IQR -> lim_inf: -84.7650 | lim_sup: 187.9350 Límites IQR -> lim_inf: 39.6750 | lim_sup: 864.4350 Límites IQR -> lim_inf: 76.5550 | lim_sup: 883.3950 Límites IQR -> lim_inf: -128.0150 | lim_sup: 1718.0250 Límites IQR -> lim_inf: -148.6950 | lim_sup: 2065.2250 Límites IQR -> lim_inf: -295.6438 | lim_sup: 1840.8863 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1271.1077 | lim_sup: 3598.6923 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -28.8571 | lim_sup: 175.7714 Límites IQR -> lim_inf: -118.5176 | lim_sup: 233.0848 Límites IQR -> lim_inf: 2968.1815 | lim_sup: 11489.9435 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -615.0057 | lim_sup: 2166.6491 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -148.5571 | lim_sup: 247.5952 Límites IQR -> lim_inf: -39.9592 | lim_sup: 287.2479 Límites IQR -> lim_inf: 2946.2187 | lim_sup: 9741.5307 Límites IQR -> lim_inf: 1071.4286 | lim_sup: 1071.4286 Límites IQR -> lim_inf: -892.3054 | lim_sup: 1487.1756 Límites IQR -> lim_inf: -345.5568 | lim_sup: 3074.4503 Límites IQR -> lim_inf: -125.8098 | lim_sup: 337.3592 Límites IQR -> lim_inf: -185.4646 | lim_sup: 506.8854 Límites IQR -> lim_inf: 4002.2655 | lim_sup: 7640.9893 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -478.6905 | lim_sup: 1068.1667 Límites IQR -> lim_inf: -1091.0092 | lim_sup: 5145.1122 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -535.4500 | lim_sup: 1020.0929 Límites IQR -> lim_inf: -291.6423 | lim_sup: 755.4958 Límites IQR -> lim_inf: 3908.0664 | lim_sup: 9492.7021 Límites IQR -> lim_inf: -80.3571 | lim_sup: 133.9286 Límites IQR -> lim_inf: -725.1530 | lim_sup: 1841.6661 Límites IQR -> lim_inf: -122.0286 | lim_sup: 203.3810 Límites IQR -> lim_inf: -94.4408 | lim_sup: 258.2330 Límites IQR -> lim_inf: 1223.9348 | lim_sup: 5704.1610 Límites IQR -> lim_inf: -114.7955 | lim_sup: 191.3259 Límites IQR -> lim_inf: -527.4030 | lim_sup: 1498.5875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -177.7214 | lim_sup: 296.2024 Límites IQR -> lim_inf: -155.8143 | lim_sup: 259.6905 Límites IQR -> lim_inf: 1320.5827 | lim_sup: 6331.1970 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -170.8545 | lim_sup: 284.7574 Límites IQR -> lim_inf: -463.8586 | lim_sup: 1158.3152 Límites IQR -> lim_inf: -119.0536 | lim_sup: 198.4226 Límites IQR -> lim_inf: -111.9643 | lim_sup: 186.6071 Límites IQR -> lim_inf: 2182.2440 | lim_sup: 4548.0536 Límites IQR -> lim_inf: -355.3571 | lim_sup: 592.2619 Límites IQR -> lim_inf: -667.8232 | lim_sup: 2094.0196 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -148.5536 | lim_sup: 247.5893 Límites IQR -> lim_inf: -166.6155 | lim_sup: 356.8036 Límites IQR -> lim_inf: 2001.5976 | lim_sup: 6575.6214 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -526.0232 | lim_sup: 876.7054 Límites IQR -> lim_inf: -97.3589 | lim_sup: 162.2649 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1438.8202 | lim_sup: 5023.5155 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -182.7950 | lim_sup: 1990.2650 Límites IQR -> lim_inf: -198.9663 | lim_sup: 1805.8238 Límites IQR -> lim_inf: -577.0950 | lim_sup: 2533.6850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -631.4100 | lim_sup: 1052.3500 Límites IQR -> lim_inf: -77.2500 | lim_sup: 128.7500 Límites IQR -> lim_inf: -182.1450 | lim_sup: 303.5750 Límites IQR -> lim_inf: -183.4538 | lim_sup: 1839.9362 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -104.0625 | lim_sup: 173.4375 Límites IQR -> lim_inf: -118.6875 | lim_sup: 197.8125 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -52.4050 | lim_sup: 1984.8350 Límites IQR -> lim_inf: -186.9887 | lim_sup: 1852.3412 Límites IQR -> lim_inf: -371.5188 | lim_sup: 4299.1313 Límites IQR -> lim_inf: -628.4450 | lim_sup: 2650.2150 Límites IQR -> lim_inf: -794.7400 | lim_sup: 1765.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -336.0000 | lim_sup: 560.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -76.5000 | lim_sup: 127.5000 Límites IQR -> lim_inf: -189.4462 | lim_sup: 1825.2837 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -171.4125 | lim_sup: 285.6875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -142.4000 | lim_sup: 2213.8200 Límites IQR -> lim_inf: -342.8487 | lim_sup: 2803.5612 Límites IQR -> lim_inf: -367.2000 | lim_sup: 612.0000 Límites IQR -> lim_inf: -179.1000 | lim_sup: 298.5000 Límites IQR -> lim_inf: -181.8375 | lim_sup: 476.7025 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -38.6250 | lim_sup: 64.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -607.8688 | lim_sup: 3361.0013 Límites IQR -> lim_inf: 79.2300 | lim_sup: 79.2300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -137.5125 | lim_sup: 229.1875 Límites IQR -> lim_inf: -291.9000 | lim_sup: 486.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 90.6875 | lim_sup: 142.1875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -727.3688 | lim_sup: 3999.8613 Límites IQR -> lim_inf: -73.4888 | lim_sup: 321.3613 Límites IQR -> lim_inf: -109.7288 | lim_sup: 2198.9213 Límites IQR -> lim_inf: -869.3025 | lim_sup: 3458.1375 Límites IQR -> lim_inf: -65.6463 | lim_sup: 534.8038 Límites IQR -> lim_inf: -29.9700 | lim_sup: 2914.6700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -570.0000 | lim_sup: 950.0000 Límites IQR -> lim_inf: 135.3575 | lim_sup: 746.5775 Límites IQR -> lim_inf: -246.8363 | lim_sup: 411.3938 Límites IQR -> lim_inf: -126.6112 | lim_sup: 687.7387 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -208.5000 | lim_sup: 347.5000 Límites IQR -> lim_inf: -1062.9250 | lim_sup: 1835.6750 Límites IQR -> lim_inf: 204.3950 | lim_sup: 1323.8750 Límites IQR -> lim_inf: -362.2500 | lim_sup: 603.7500 Límites IQR -> lim_inf: -414.7387 | lim_sup: 1655.1912 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -268.8025 | lim_sup: 656.2175 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -404.2125 | lim_sup: 673.6875 Límites IQR -> lim_inf: -102.9375 | lim_sup: 171.5625 Límites IQR -> lim_inf: -39.6463 | lim_sup: 1068.8438 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -204.4050 | lim_sup: 340.6750 Límites IQR -> lim_inf: -632.6400 | lim_sup: 1054.4000 Límites IQR -> lim_inf: 739.1000 | lim_sup: 739.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -237.7650 | lim_sup: 1625.8350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -123.4425 | lim_sup: 205.7375 Límites IQR -> lim_inf: -469.3388 | lim_sup: 782.2313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -466.3088 | lim_sup: 2252.4812 Límites IQR -> lim_inf: 144.1864 | lim_sup: 442.1864 Límites IQR -> lim_inf: -373.6364 | lim_sup: 804.5455 Límites IQR -> lim_inf: -2082.1989 | lim_sup: 5311.3193 Límites IQR -> lim_inf: 5515.3636 | lim_sup: 5609.9091 Límites IQR -> lim_inf: -340.9091 | lim_sup: 568.1818 Límites IQR -> lim_inf: 14214.4625 | lim_sup: 18688.8352 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -171.0637 | lim_sup: 285.1062 Límites IQR -> lim_inf: -87.7425 | lim_sup: 146.2375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -139.2250 | lim_sup: 1292.5550 Límites IQR -> lim_inf: 95.1600 | lim_sup: 95.1600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -148.5637 | lim_sup: 247.6062 Límites IQR -> lim_inf: -325.9162 | lim_sup: 543.1938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -258.6488 | lim_sup: 461.9612 Límites IQR -> lim_inf: -75.3000 | lim_sup: 1243.7400 Límites IQR -> lim_inf: 65.9350 | lim_sup: 889.8950 Límites IQR -> lim_inf: -51.6700 | lim_sup: 1368.1700 Límites IQR -> lim_inf: 142.0850 | lim_sup: 1413.9650 Límites IQR -> lim_inf: -26.3500 | lim_sup: 1652.7700 Límites IQR -> lim_inf: 7264.5945 | lim_sup: 7264.5945 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1094.7757 | lim_sup: 4317.5779 Límites IQR -> lim_inf: -8.9432 | lim_sup: 7699.3567 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 5423.2310 | lim_sup: 20785.4750 Límites IQR -> lim_inf: 968.2943 | lim_sup: 3716.7648 Límites IQR -> lim_inf: 11353.0716 | lim_sup: 35570.5706 Límites IQR -> lim_inf: -501.1950 | lim_sup: 1117.7250 Límites IQR -> lim_inf: 412.6750 | lim_sup: 1117.6750 Límites IQR -> lim_inf: 1120.8275 | lim_sup: 1778.8075 Límites IQR -> lim_inf: 85.4125 | lim_sup: 221.0325 Límites IQR -> lim_inf: -669.5300 | lim_sup: 1739.3500 Límites IQR -> lim_inf: -470.3225 | lim_sup: 1600.3975 Límites IQR -> lim_inf: -521.6475 | lim_sup: 2370.5525 Límites IQR -> lim_inf: -579.5350 | lim_sup: 1074.5650 Límites IQR -> lim_inf: -1072.9650 | lim_sup: 3292.1350 Límites IQR -> lim_inf: -238.7400 | lim_sup: 1524.7000 Límites IQR -> lim_inf: -359.8488 | lim_sup: 2071.6213 Límites IQR -> lim_inf: -144.2308 | lim_sup: 240.3846 Límites IQR -> lim_inf: -334.5577 | lim_sup: 557.5962 Límites IQR -> lim_inf: -973.0104 | lim_sup: 2082.3847 Límites IQR -> lim_inf: 8.4433 | lim_sup: 408.5388 Límites IQR -> lim_inf: 279.6693 | lim_sup: 2214.6784 Límites IQR -> lim_inf: -437.9550 | lim_sup: 898.0850 Límites IQR -> lim_inf: 87.0925 | lim_sup: 2016.5925 Límites IQR -> lim_inf: -9.1500 | lim_sup: 1905.4100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -175.6912 | lim_sup: 292.8188 Límites IQR -> lim_inf: -251.3950 | lim_sup: 564.4850 Límites IQR -> lim_inf: -77.2500 | lim_sup: 128.7500 Límites IQR -> lim_inf: -209.3587 | lim_sup: 348.9312 Límites IQR -> lim_inf: 42.4250 | lim_sup: 2253.9050 Límites IQR -> lim_inf: -876.9875 | lim_sup: 3034.5525 Límites IQR -> lim_inf: -128.6014 | lim_sup: 460.4895 Límites IQR -> lim_inf: -3376.6744 | lim_sup: 14092.5618 Límites IQR -> lim_inf: 238.4615 | lim_sup: 546.1538 Límites IQR -> lim_inf: -138.4615 | lim_sup: 230.7692 Límites IQR -> lim_inf: 20142.5679 | lim_sup: 40164.9422 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -216.0187 | lim_sup: 360.0312 Límites IQR -> lim_inf: -461.2500 | lim_sup: 768.7500 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -62.4938 | lim_sup: 1401.5363 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -82.5000 | lim_sup: 137.5000 Límites IQR -> lim_inf: -260.6250 | lim_sup: 434.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -433.6237 | lim_sup: 2189.8262 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -329.6925 | lim_sup: 549.4875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 61.2600 | lim_sup: 61.2600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -242.8200 | lim_sup: 404.7000 Límites IQR -> lim_inf: -759.3750 | lim_sup: 1265.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -192.0000 | lim_sup: 320.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -138.9900 | lim_sup: 2677.1300 Límites IQR -> lim_inf: -221.8650 | lim_sup: 369.7750 Límites IQR -> lim_inf: -265.2300 | lim_sup: 442.0500 Límites IQR -> lim_inf: 250.0000 | lim_sup: 250.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 95.7200 | lim_sup: 95.7200 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: -263.3100 | lim_sup: 438.8500 Límites IQR -> lim_inf: -386.7225 | lim_sup: 644.5375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -38.6250 | lim_sup: 64.3750 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: -342.2775 | lim_sup: 4116.2425 Límites IQR -> lim_inf: 47.9000 | lim_sup: 47.9000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -203.6025 | lim_sup: 339.3375 Límites IQR -> lim_inf: -91.0875 | lim_sup: 151.8125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -19.3125 | lim_sup: 32.1875 Límites IQR -> lim_inf: 203.0000 | lim_sup: 203.0000 Límites IQR -> lim_inf: -166.1962 | lim_sup: 2169.1737 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -177.4350 | lim_sup: 295.7250 Límites IQR -> lim_inf: -246.7575 | lim_sup: 411.2625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -475.6612 | lim_sup: 792.7687 Límites IQR -> lim_inf: -148.5262 | lim_sup: 1443.8437 Límites IQR -> lim_inf: -523.5000 | lim_sup: 872.5000 Límites IQR -> lim_inf: -163.7175 | lim_sup: 272.8625 Límites IQR -> lim_inf: -499.2525 | lim_sup: 832.0875 Límites IQR -> lim_inf: -38.6250 | lim_sup: 64.3750 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: -407.6850 | lim_sup: 2344.7150 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -304.5000 | lim_sup: 507.5000 Límites IQR -> lim_inf: -268.7400 | lim_sup: 447.9000 Límites IQR -> lim_inf: 44.6250 | lim_sup: 49.2250 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -392.8563 | lim_sup: 3381.7938 Límites IQR -> lim_inf: 96.1800 | lim_sup: 96.1800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -155.5200 | lim_sup: 259.2000 Límites IQR -> lim_inf: -728.3963 | lim_sup: 1213.9938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -674.6237 | lim_sup: 3877.3863 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -198.2700 | lim_sup: 330.4500 Límites IQR -> lim_inf: -243.8288 | lim_sup: 406.3813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -38.6250 | lim_sup: 64.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -279.2800 | lim_sup: 2125.1800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -279.9075 | lim_sup: 466.5125 Límites IQR -> lim_inf: -209.9738 | lim_sup: 349.9563 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 52.9500 | lim_sup: 52.9500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -141.1500 | lim_sup: 235.2500 Límites IQR -> lim_inf: -104.2612 | lim_sup: 173.7687 Límites IQR -> lim_inf: 394.4750 | lim_sup: 416.5750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -165.7338 | lim_sup: 2307.7963 Límites IQR -> lim_inf: 342.6400 | lim_sup: 342.6400 Límites IQR -> lim_inf: -217.0500 | lim_sup: 361.7500 Límites IQR -> lim_inf: -29.6400 | lim_sup: 49.4000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -69.0563 | lim_sup: 115.0938 Límites IQR -> lim_inf: -954.3600 | lim_sup: 1590.6000 Límites IQR -> lim_inf: -232.0075 | lim_sup: 898.1725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1897.6050 | lim_sup: 2094.7650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -540.1750 | lim_sup: 3358.7850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -36.1125 | lim_sup: 60.1875 Límites IQR -> lim_inf: -479.4000 | lim_sup: 799.0000 Límites IQR -> lim_inf: -1012.6237 | lim_sup: 1687.7062 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 463.2200 | lim_sup: 773.5400 Límites IQR -> lim_inf: -75.0000 | lim_sup: 125.0000 Límites IQR -> lim_inf: -64.8400 | lim_sup: 5235.5200 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -180.0000 | lim_sup: 300.0000 Límites IQR -> lim_inf: -153.7500 | lim_sup: 256.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -50.9250 | lim_sup: 84.8750 Límites IQR -> lim_inf: -150.1212 | lim_sup: 1783.9887 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -231.4575 | lim_sup: 385.7625 Límites IQR -> lim_inf: -404.8763 | lim_sup: 674.7938 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 28.0375 | lim_sup: 1415.2175 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.5000 | lim_sup: 337.5000 Límites IQR -> lim_inf: -436.5750 | lim_sup: 727.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -146.7000 | lim_sup: 244.5000 Límites IQR -> lim_inf: -223.8500 | lim_sup: 1822.3100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -207.7687 | lim_sup: 346.2812 Límites IQR -> lim_inf: -464.6025 | lim_sup: 774.3375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -12.7313 | lim_sup: 21.2188 Límites IQR -> lim_inf: -88.9125 | lim_sup: 1733.1475 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.7738 | lim_sup: 337.9563 Límites IQR -> lim_inf: -348.1688 | lim_sup: 580.2812 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -152.5750 | lim_sup: 1647.1850 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -207.7687 | lim_sup: 346.2812 Límites IQR -> lim_inf: -936.4275 | lim_sup: 1560.7125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 77.6775 | lim_sup: 999.5175 Límites IQR -> lim_inf: 46.0500 | lim_sup: 46.0500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -333.4500 | lim_sup: 555.7500 Límites IQR -> lim_inf: -389.0150 | lim_sup: 702.8650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -96.0000 | lim_sup: 160.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -328.3100 | lim_sup: 2264.9100 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -153.7500 | lim_sup: 256.2500 Límites IQR -> lim_inf: -538.5000 | lim_sup: 897.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -223.2975 | lim_sup: 1462.6425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -857.1675 | lim_sup: 1497.2125 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -154.5000 | lim_sup: 257.5000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: 137.6600 | lim_sup: 1459.3400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -286.5000 | lim_sup: 477.5000 Límites IQR -> lim_inf: -402.2663 | lim_sup: 670.4438 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: -63.6938 | lim_sup: 106.1563 Límites IQR -> lim_inf: 0.5300 | lim_sup: 1222.1100 Límites IQR -> lim_inf: 53.7300 | lim_sup: 53.7300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -208.3988 | lim_sup: 347.3313 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -50.9250 | lim_sup: 84.8750 Límites IQR -> lim_inf: -119.6775 | lim_sup: 2073.3225 Límites IQR -> lim_inf: 78.2900 | lim_sup: 78.2900 Límites IQR -> lim_inf: -37.5000 | lim_sup: 62.5000 Límites IQR -> lim_inf: -192.0938 | lim_sup: 320.1562 Límites IQR -> lim_inf: -198.7500 | lim_sup: 331.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -969.0687 | lim_sup: 3997.3412 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -269.3850 | lim_sup: 448.9750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -316.8962 | lim_sup: 2043.4537 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -161.2500 | lim_sup: 268.7500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -223.1362 | lim_sup: 371.8937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -182.1450 | lim_sup: 303.5750 Límites IQR -> lim_inf: 32.1113 | lim_sup: 1386.5013 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -153.7500 | lim_sup: 256.2500 Límites IQR -> lim_inf: -191.6250 | lim_sup: 319.3750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -287.6025 | lim_sup: 1878.5775 Límites IQR -> lim_inf: -203.2500 | lim_sup: 338.7500 Límites IQR -> lim_inf: -219.9375 | lim_sup: 366.5625 Límites IQR -> lim_inf: -311.3700 | lim_sup: 518.9500 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -169.2275 | lim_sup: 1479.2725 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -198.7500 | lim_sup: 331.2500 Límites IQR -> lim_inf: -121.1400 | lim_sup: 201.9000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -306.8425 | lim_sup: 1864.9375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -217.6687 | lim_sup: 362.7812 Límites IQR -> lim_inf: -271.0013 | lim_sup: 451.6688 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -252.3025 | lim_sup: 2394.8975 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -195.0000 | lim_sup: 325.0000 Límites IQR -> lim_inf: -424.5000 | lim_sup: 707.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -79.9125 | lim_sup: 1730.3275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -209.6737 | lim_sup: 349.4562 Límites IQR -> lim_inf: -187.3237 | lim_sup: 312.2062 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -51.0000 | lim_sup: 85.0000 Límites IQR -> lim_inf: -145.6550 | lim_sup: 1760.1650 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -186.7500 | lim_sup: 311.2500 Límites IQR -> lim_inf: -329.9513 | lim_sup: 549.9188 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -25.5000 | lim_sup: 42.5000 Límites IQR -> lim_inf: -98.8212 | lim_sup: 1352.0887 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -112.5000 | lim_sup: 187.5000 Límites IQR -> lim_inf: -347.5163 | lim_sup: 579.1938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 40.6988 | lim_sup: 1252.6287 Límites IQR -> lim_inf: 55.5000 | lim_sup: 55.5000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -231.0450 | lim_sup: 385.0750 Límites IQR -> lim_inf: -572.7750 | lim_sup: 954.6250 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -182.1212 | lim_sup: 2320.8687 Límites IQR -> lim_inf: 530.0000 | lim_sup: 530.0000 Límites IQR -> lim_inf: -181.3500 | lim_sup: 302.2500 Límites IQR -> lim_inf: -668.1750 | lim_sup: 1113.6250 Límites IQR -> lim_inf: -91.5000 | lim_sup: 152.5000 Límites IQR -> lim_inf: -118.6500 | lim_sup: 197.7500 Límites IQR -> lim_inf: -279.9125 | lim_sup: 2171.0875 Límites IQR -> lim_inf: 97.3900 | lim_sup: 97.3900 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -732.3900 | lim_sup: 1220.6500 Límites IQR -> lim_inf: -511.5225 | lim_sup: 852.5375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1056.2700 | lim_sup: 1815.6300 Límites IQR -> lim_inf: -163.9275 | lim_sup: 273.2125 Límites IQR -> lim_inf: -819.6162 | lim_sup: 4777.9737 Límites IQR -> lim_inf: 133.3100 | lim_sup: 133.3100 Límites IQR -> lim_inf: -92.3625 | lim_sup: 153.9375 Límites IQR -> lim_inf: -891.1163 | lim_sup: 1485.1937 Límites IQR -> lim_inf: -632.1000 | lim_sup: 1117.1000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 1336.4450 | lim_sup: 2297.2050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -955.3075 | lim_sup: 6041.9325 Límites IQR -> lim_inf: -286.2488 | lim_sup: 477.0813 Límites IQR -> lim_inf: -1293.1500 | lim_sup: 2155.2500 Límites IQR -> lim_inf: -1203.9050 | lim_sup: 2548.8750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 3465.9088 | lim_sup: 4290.8988 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1263.3350 | lim_sup: 8156.1850 Límites IQR -> lim_inf: 233.4000 | lim_sup: 233.4000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1143.9225 | lim_sup: 1906.5375 Límites IQR -> lim_inf: -634.4662 | lim_sup: 1569.5437 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2941.1763 | lim_sup: 3895.9862 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -1044.2863 | lim_sup: 8980.4637 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -680.4000 | lim_sup: 1134.0000 Límites IQR -> lim_inf: -494.4100 | lim_sup: 871.5300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -537.9262 | lim_sup: 896.5438 Límites IQR -> lim_inf: 156.0525 | lim_sup: 280.3525 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -138.4000 | lim_sup: 3386.4000 Límites IQR -> lim_inf: -1351.6238 | lim_sup: 4551.9662 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -60.9000 | lim_sup: 686.5400 Límites IQR -> lim_inf: -827.4375 | lim_sup: 1379.0625 Límites IQR -> lim_inf: -555.8713 | lim_sup: 39980.6588 Límites IQR -> lim_inf: -47.5500 | lim_sup: 1402.2300 Límites IQR -> lim_inf: 264.0450 | lim_sup: 1175.2050 Límites IQR -> lim_inf: -407.4125 | lim_sup: 1044.8275 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -30.0000 | lim_sup: 50.0000 Límites IQR -> lim_inf: -409.0350 | lim_sup: 681.7250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -847.2463 | lim_sup: 3671.3438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -314.3550 | lim_sup: 523.9250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -279.7962 | lim_sup: 1706.0938 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -311.2500 | lim_sup: 518.7500 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -106.1262 | lim_sup: 1578.0237 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -394.6575 | lim_sup: 953.4025 Límites IQR -> lim_inf: 103.6250 | lim_sup: 142.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -59.3943 | lim_sup: 261.3966 Límites IQR -> lim_inf: 31.8182 | lim_sup: 68.1818 Límites IQR -> lim_inf: -1317.5352 | lim_sup: 3559.8557 Límites IQR -> lim_inf: 4731.7000 | lim_sup: 4942.6091 Límites IQR -> lim_inf: -317.5568 | lim_sup: 529.2614 Límites IQR -> lim_inf: 6306.0432 | lim_sup: 11320.8614 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -470.9863 | lim_sup: 1195.6438 Límites IQR -> lim_inf: -1103.0075 | lim_sup: 1932.1725 Límites IQR -> lim_inf: -1692.9825 | lim_sup: 3049.6375 Límites IQR -> lim_inf: 1549.8525 | lim_sup: 3937.0725 Límites IQR -> lim_inf: -33.7500 | lim_sup: 56.2500 Límites IQR -> lim_inf: -1422.3050 | lim_sup: 5857.5150 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -29.3063 | lim_sup: 48.8438 Límites IQR -> lim_inf: -376.9425 | lim_sup: 628.2375 Límites IQR -> lim_inf: -870.0300 | lim_sup: 1450.0500 Límites IQR -> lim_inf: 527.9950 | lim_sup: 1071.0750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -202.1563 | lim_sup: 1633.6738 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 373.6150 | lim_sup: 662.4230 Límites IQR -> lim_inf: 55.9464 | lim_sup: 2575.9044 Límites IQR -> lim_inf: 1778.2577 | lim_sup: 4351.7958 Límites IQR -> lim_inf: -1287.9386 | lim_sup: 2730.8514 Límites IQR -> lim_inf: 4137.6129 | lim_sup: 11539.6599 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 163.4475 | lim_sup: 478.8475 Límites IQR -> lim_inf: -442.8429 | lim_sup: 2561.8646 Límites IQR -> lim_inf: 1560.5034 | lim_sup: 9097.6004 Límites IQR -> lim_inf: -330.4948 | lim_sup: 1491.8807 Límites IQR -> lim_inf: 4610.7330 | lim_sup: 13665.9050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 216.1610 | lim_sup: 807.9570 Límites IQR -> lim_inf: -891.1878 | lim_sup: 2625.6462 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 2587.4108 | lim_sup: 5519.3153 Límites IQR -> lim_inf: -365.7247 | lim_sup: 1750.4628 Límites IQR -> lim_inf: 7524.9766 | lim_sup: 26941.7836 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 191.5165 | lim_sup: 191.5165 Límites IQR -> lim_inf: -1626.8031 | lim_sup: 4392.7384 Límites IQR -> lim_inf: -574.0577 | lim_sup: 9054.9243 Límites IQR -> lim_inf: -476.9712 | lim_sup: 1225.2037 Límites IQR -> lim_inf: 6154.9976 | lim_sup: 12488.9406 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 626.6308 | lim_sup: 759.3847 Límites IQR -> lim_inf: -1684.5631 | lim_sup: 6869.7239 Límites IQR -> lim_inf: 4871.3888 | lim_sup: 9239.3867 Límites IQR -> lim_inf: -3742.1814 | lim_sup: 8670.7161 Límites IQR -> lim_inf: 3740.8239 | lim_sup: 31939.5059 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 732.7387 | lim_sup: 746.0408 Límites IQR -> lim_inf: -1458.3326 | lim_sup: 3721.6544 Límites IQR -> lim_inf: 2009.0582 | lim_sup: 9160.6083 Límites IQR -> lim_inf: -877.6787 | lim_sup: 2200.0978 Límites IQR -> lim_inf: 562.1781 | lim_sup: 30958.1466 Límites IQR -> lim_inf: 3541.9845 | lim_sup: 3541.9845 Límites IQR -> lim_inf: -16.6444 | lim_sup: 27.7406 Límites IQR -> lim_inf: 1264.6299 | lim_sup: 2666.8217 Límites IQR -> lim_inf: -329.3858 | lim_sup: 5485.3096 Límites IQR -> lim_inf: 3672.2908 | lim_sup: 10816.5527 Límites IQR -> lim_inf: -523.2810 | lim_sup: 3589.6010 Límites IQR -> lim_inf: 3065.1500 | lim_sup: 36986.2720 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -231.0863 | lim_sup: 385.1438 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -58.2925 | lim_sup: 1334.9675 Límites IQR -> lim_inf: 1130.0000 | lim_sup: 1130.0000 Límites IQR -> lim_inf: 20.3238 | lim_sup: 60.0337 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -248.8050 | lim_sup: 599.9950 Límites IQR -> lim_inf: -227.7650 | lim_sup: 349.7350 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -38.3400 | lim_sup: 63.9000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -278.2575 | lim_sup: 463.7625 Límites IQR -> lim_inf: -646.5225 | lim_sup: 1077.5375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 68.0000 | lim_sup: 68.0000 Límites IQR -> lim_inf: -116.5425 | lim_sup: 1878.3175 Límites IQR -> lim_inf: 200.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -372.7650 | lim_sup: 1168.4750 Límites IQR -> lim_inf: -915.8225 | lim_sup: 1998.7575 Límites IQR -> lim_inf: -3438.8862 | lim_sup: 9103.5437 Límites IQR -> lim_inf: 1988.6250 | lim_sup: 2746.8250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -23.9975 | lim_sup: 2044.6425 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -744.0775 | lim_sup: 1456.3425 Límites IQR -> lim_inf: 64.0000 | lim_sup: 64.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -335.4412 | lim_sup: 2730.5087 Límites IQR -> lim_inf: 746.3500 | lim_sup: 746.3500 Límites IQR -> lim_inf: -0.9350 | lim_sup: 49.2050 Límites IQR -> lim_inf: 276.9231 | lim_sup: 276.9231 Límites IQR -> lim_inf: -1194.7328 | lim_sup: 1991.2213 Límites IQR -> lim_inf: -122.0250 | lim_sup: 337.0350 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -570.7012 | lim_sup: 951.1687 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 337.5400 | lim_sup: 587.5400 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -72.9727 | lim_sup: 572.4818 Límites IQR -> lim_inf: 1699.8818 | lim_sup: 1699.8818 Límites IQR -> lim_inf: -935.3148 | lim_sup: 2813.4034 Límites IQR -> lim_inf: 2310.2636 | lim_sup: 6404.4455 Límites IQR -> lim_inf: -833.8091 | lim_sup: 1389.6818 Límites IQR -> lim_inf: 7055.0205 | lim_sup: 14600.0932 Límites IQR -> lim_inf: 80.9091 | lim_sup: 80.9091 Límites IQR -> lim_inf: -657.3670 | lim_sup: 1735.8784 Límites IQR -> lim_inf: -6923.0000 | lim_sup: 11992.6364 Límites IQR -> lim_inf: -6176.7727 | lim_sup: 11490.5000 Límites IQR -> lim_inf: 15134.8886 | lim_sup: 18107.8159 Límites IQR -> lim_inf: 2336.5273 | lim_sup: 2336.5273 Límites IQR -> lim_inf: 54.5455 | lim_sup: 54.5455 Límites IQR -> lim_inf: -1154.5000 | lim_sup: 2354.5182 Límites IQR -> lim_inf: 3726.9045 | lim_sup: 5692.2500 Límites IQR -> lim_inf: -262.9659 | lim_sup: 1078.4886 Límites IQR -> lim_inf: 10317.9318 | lim_sup: 14226.1864 Límites IQR -> lim_inf: -170.6250 | lim_sup: 284.3750 Límites IQR -> lim_inf: 23.4400 | lim_sup: 23.4400 Límites IQR -> lim_inf: -104.2963 | lim_sup: 462.7138 Límites IQR -> lim_inf: -79.8135 | lim_sup: 558.6945 Límites IQR -> lim_inf: -371.2627 | lim_sup: 933.6511 Límites IQR -> lim_inf: -46.7610 | lim_sup: 77.9350 Límites IQR -> lim_inf: 714.3247 | lim_sup: 9609.3202 Límites IQR -> lim_inf: -136.6691 | lim_sup: 956.6834 Límites IQR -> lim_inf: -782.4791 | lim_sup: 1984.9775 Límites IQR -> lim_inf: -137.2500 | lim_sup: 228.7500 Límites IQR -> lim_inf: 1498.2337 | lim_sup: 2790.8538 Límites IQR -> lim_inf: 74.6806 | lim_sup: 146.9456 Límites IQR -> lim_inf: -729.0134 | lim_sup: 2243.5208 Límites IQR -> lim_inf: -214.1972 | lim_sup: 435.0220 Límites IQR -> lim_inf: 2336.8250 | lim_sup: 6512.4889 Límites IQR -> lim_inf: 504.8856 | lim_sup: 504.8856 Límites IQR -> lim_inf: -709.4508 | lim_sup: 1949.0580 Límites IQR -> lim_inf: -47.9850 | lim_sup: 79.9750 Límites IQR -> lim_inf: 1019.7511 | lim_sup: 4206.2760 Límites IQR -> lim_inf: -109.4315 | lim_sup: 766.0202 Límites IQR -> lim_inf: -969.3422 | lim_sup: 3936.2725 Límites IQR -> lim_inf: -252.0500 | lim_sup: 478.7500 Límites IQR -> lim_inf: 429.7858 | lim_sup: 17359.4473 Límites IQR -> lim_inf: -60.0000 | lim_sup: 420.0000 Límites IQR -> lim_inf: -484.4056 | lim_sup: 1166.9061 Límites IQR -> lim_inf: -7.1350 | lim_sup: 65.2250 Límites IQR -> lim_inf: 1034.5318 | lim_sup: 5363.7200 Límites IQR -> lim_inf: -27.4909 | lim_sup: 405.4545 Límites IQR -> lim_inf: -159.8045 | lim_sup: 266.3409 Límites IQR -> lim_inf: -783.1920 | lim_sup: 1827.4716 Límites IQR -> lim_inf: -468.8614 | lim_sup: 8004.0295 Límites IQR -> lim_inf: -587.6864 | lim_sup: 979.4773 Límites IQR -> lim_inf: 4837.6250 | lim_sup: 9372.3705 Límites IQR -> lim_inf: 140.4102 | lim_sup: 532.4557 Límites IQR -> lim_inf: -1697.4364 | lim_sup: 3155.7273 Límites IQR -> lim_inf: -1397.9886 | lim_sup: 4959.9023 Límites IQR -> lim_inf: 6586.5341 | lim_sup: 6751.3523 Límites IQR -> lim_inf: -415.9091 | lim_sup: 693.1818 Límites IQR -> lim_inf: 15239.2864 | lim_sup: 20850.2682 Límites IQR -> lim_inf: -63.6226 | lim_sup: 106.0377 Límites IQR -> lim_inf: 195.4615 | lim_sup: 195.4615 Límites IQR -> lim_inf: -137.5442 | lim_sup: 229.2403 Límites IQR -> lim_inf: -1.3135 | lim_sup: 543.5431 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -737.3250 | lim_sup: 1228.8750 Límites IQR -> lim_inf: 148.7350 | lim_sup: 1086.7750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -80.4238 | lim_sup: 469.0263 Límites IQR -> lim_inf: -653.6800 | lim_sup: 3786.8000 Límites IQR -> lim_inf: -36.2800 | lim_sup: 958.2800 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 299.0200 | lim_sup: 299.0200 Límites IQR -> lim_inf: -1133.9713 | lim_sup: 2465.6188 Límites IQR -> lim_inf: 150.9000 | lim_sup: 580.1000 Límites IQR -> lim_inf: -308.2537 | lim_sup: 513.7562 Límites IQR -> lim_inf: -177.1113 | lim_sup: 7657.1188 Límites IQR -> lim_inf: 50.8838 | lim_sup: 1130.0738 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -51.3675 | lim_sup: 85.6125 Límites IQR -> lim_inf: -550.2562 | lim_sup: 917.0937 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 26.8175 | lim_sup: 104.6375 Límites IQR -> lim_inf: -57.0725 | lim_sup: 185.7875 Límites IQR -> lim_inf: 80.9091 | lim_sup: 80.9091 Límites IQR -> lim_inf: -1781.5057 | lim_sup: 3468.2670 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 4199.0000 | lim_sup: 4508.0909 Límites IQR -> lim_inf: -151.4500 | lim_sup: 1001.3864 Límites IQR -> lim_inf: 10226.9182 | lim_sup: 14955.1364 Límites IQR -> lim_inf: 242.1125 | lim_sup: 351.5725 Límites IQR -> lim_inf: 64.0900 | lim_sup: 94.2500 Límites IQR -> lim_inf: -209.0624 | lim_sup: 1767.4978 Límites IQR -> lim_inf: -58.2750 | lim_sup: 97.1250 Límites IQR -> lim_inf: 539.7303 | lim_sup: 14699.0616 Límites IQR -> lim_inf: -177.8413 | lim_sup: 633.6888 Límites IQR -> lim_inf: -45.3000 | lim_sup: 454.0600 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -66.0000 | lim_sup: 110.0000 Límites IQR -> lim_inf: -511.0425 | lim_sup: 851.7375 Límites IQR -> lim_inf: 64.0000 | lim_sup: 64.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 163.5250 | lim_sup: 2081.8050 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -345.6900 | lim_sup: 576.1500 Límites IQR -> lim_inf: -644.2650 | lim_sup: 1073.7750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -116.7300 | lim_sup: 194.5500 Límites IQR -> lim_inf: -45.5363 | lim_sup: 75.8938 Límites IQR -> lim_inf: 269.0000 | lim_sup: 269.0000 Límites IQR -> lim_inf: -153.0675 | lim_sup: 255.1125 Límites IQR -> lim_inf: -384.9825 | lim_sup: 641.6375 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -283.1250 | lim_sup: 471.8750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -817.1250 | lim_sup: 1361.8750 Límites IQR -> lim_inf: 4551.4000 | lim_sup: 4551.4000 Límites IQR -> lim_inf: 389.8700 | lim_sup: 561.8700 Límites IQR -> lim_inf: -93.7500 | lim_sup: 156.2500 Límites IQR -> lim_inf: 364.0000 | lim_sup: 364.0000 Límites IQR -> lim_inf: -90.0000 | lim_sup: 150.0000 Límites IQR -> lim_inf: -772.5375 | lim_sup: 1287.5625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: -453.0000 | lim_sup: 755.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 284.0000 | lim_sup: 284.0000 Límites IQR -> lim_inf: -138.3750 | lim_sup: 230.6250 Límites IQR -> lim_inf: -333.7500 | lim_sup: 556.2500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -98.2500 | lim_sup: 163.7500 Límites IQR -> lim_inf: -392.1188 | lim_sup: 653.5312 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -197.7650 | lim_sup: 2551.7950 Límites IQR -> lim_inf: 461.2455 | lim_sup: 461.2455 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -122.7273 | lim_sup: 204.5455 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -51.3675 | lim_sup: 85.6125 Límites IQR -> lim_inf: -17.5725 | lim_sup: 29.2875 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -217.5000 | lim_sup: 362.5000 Límites IQR -> lim_inf: -102.0000 | lim_sup: 170.0000 Límites IQR -> lim_inf: -386.7375 | lim_sup: 2033.0625 Límites IQR -> lim_inf: -586.4700 | lim_sup: 2083.6900 Límites IQR -> lim_inf: -89.7750 | lim_sup: 149.6250 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 53.0300 | lim_sup: 191.4300 Límites IQR -> lim_inf: -159.3750 | lim_sup: 265.6250 Límites IQR -> lim_inf: 1100.4500 | lim_sup: 1100.4500 Límites IQR -> lim_inf: -221.2500 | lim_sup: 368.7500 Límites IQR -> lim_inf: 240.0000 | lim_sup: 240.0000 Límites IQR -> lim_inf: 40.2875 | lim_sup: 425.1075 Límites IQR -> lim_inf: 162.4750 | lim_sup: 332.4150 Límites IQR -> lim_inf: 348.4550 | lim_sup: 1325.3750 Límites IQR -> lim_inf: 141.7000 | lim_sup: 1191.7800 Límites IQR -> lim_inf: 130.6100 | lim_sup: 549.6700 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 763.4030 | lim_sup: 763.4030 Límites IQR -> lim_inf: 209.6341 | lim_sup: 4191.8366 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 6782.3375 | lim_sup: 8589.3175 Límites IQR -> lim_inf: -1763.2312 | lim_sup: 9886.8187 Límites IQR -> lim_inf: 232.5183 | lim_sup: 22010.4973 Límites IQR -> lim_inf: 241.9100 | lim_sup: 241.9100 Límites IQR -> lim_inf: -1287.9850 | lim_sup: 2777.6750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 477.5000 | lim_sup: 477.5000 Límites IQR -> lim_inf: -756.5288 | lim_sup: 1260.8813 Límites IQR -> lim_inf: -32.6125 | lim_sup: 494.8475 Límites IQR -> lim_inf: -90.4500 | lim_sup: 150.7500 Límites IQR -> lim_inf: -74.2768 | lim_sup: 123.7946 Límites IQR -> lim_inf: 1500.0000 | lim_sup: 1500.0000 Límites IQR -> lim_inf: -2526.9722 | lim_sup: 4991.3537 Límites IQR -> lim_inf: -1619.3764 | lim_sup: 5001.8196 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 8634.8700 | lim_sup: 8634.8700 Límites IQR -> lim_inf: -826.6700 | lim_sup: 1602.8500 Límites IQR -> lim_inf: -20995.3531 | lim_sup: 38589.6464 Límites IQR -> lim_inf: -260.9250 | lim_sup: 1453.8550 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -175.3575 | lim_sup: 292.2625 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -356.2500 | lim_sup: 893.7500 Límites IQR -> lim_inf: -190.7025 | lim_sup: 317.8375 Límites IQR -> lim_inf: -262.7225 | lim_sup: 1019.6175 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 77.8200 | lim_sup: 77.8200 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -317.9725 | lim_sup: 1556.6075 Límites IQR -> lim_inf: -217.6950 | lim_sup: 872.5650 Límites IQR -> lim_inf: -215.4825 | lim_sup: 359.1375 Límites IQR -> lim_inf: -667.2300 | lim_sup: 1112.0500 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -58.3650 | lim_sup: 97.2750 Límites IQR -> lim_inf: 121.4300 | lim_sup: 121.4300 Límites IQR -> lim_inf: -274.0913 | lim_sup: 1435.3588 Límites IQR -> lim_inf: -206.4938 | lim_sup: 453.8963 Límites IQR -> lim_inf: -217.7325 | lim_sup: 362.8875 Límites IQR -> lim_inf: -766.9088 | lim_sup: 1622.1813 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -58.3650 | lim_sup: 97.2750 Límites IQR -> lim_inf: -122.0362 | lim_sup: 203.3938 Límites IQR -> lim_inf: -78.4938 | lim_sup: 1447.7163 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -120.0000 | lim_sup: 200.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 152.0000 | lim_sup: 152.0000 Límites IQR -> lim_inf: 154.7000 | lim_sup: 154.7000 Límites IQR -> lim_inf: -34.1250 | lim_sup: 146.2350 Límites IQR -> lim_inf: -17.6625 | lim_sup: 188.5775 Límites IQR -> lim_inf: 22.7237 | lim_sup: 36.0938 Límites IQR -> lim_inf: 428.0000 | lim_sup: 428.0000 Límites IQR -> lim_inf: 168.6660 | lim_sup: 168.6660 Límites IQR -> lim_inf: 291.6067 | lim_sup: 1601.9888 Límites IQR -> lim_inf: -7623.2005 | lim_sup: 21970.8795 Límites IQR -> lim_inf: -465.7650 | lim_sup: 776.2750 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 24.3337 | lim_sup: 54.1638 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: -139.1250 | lim_sup: 231.8750 Límites IQR -> lim_inf: -761.6400 | lim_sup: 1878.2000 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 51.5000 | lim_sup: 51.5000 Límites IQR -> lim_inf: -462.0300 | lim_sup: 1690.0900 Límites IQR -> lim_inf: -651.0875 | lim_sup: 2074.6525 Límites IQR -> lim_inf: -186.7500 | lim_sup: 311.2500 Límites IQR -> lim_inf: -750.0100 | lim_sup: 1309.4300 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 51.5000 | lim_sup: 51.5000 Límites IQR -> lim_inf: 49.3000 | lim_sup: 49.3000 Límites IQR -> lim_inf: -334.7350 | lim_sup: 1727.6450 Límites IQR -> lim_inf: -510.0000 | lim_sup: 1990.0000 Límites IQR -> lim_inf: 109.2100 | lim_sup: 109.2100 Límites IQR -> lim_inf: -720.7975 | lim_sup: 1290.5825 Límites IQR -> lim_inf: 0.0000 | lim_sup: 0.0000 Límites IQR -> lim_inf: 80.1500 | lim_sup: 80.1500 Límites IQR -> lim_inf: -156.7725 | lim_sup: 1028.8475 Límites IQR -> lim_inf: -378.9900 | lim_sup: 1706.1140 Límites IQR -> lim_inf: -39.4003 | lim_sup: 84.3674 Límites IQR -> lim_inf: -14807.2421 | lim_sup: 35168.2489 Límites IQR -> lim_inf: -161.2000 | lim_sup: 486.0000 Límites IQR -> lim_inf: -152.6970 | lim_sup: 310.1030 Límites IQR -> lim_inf: -3601.5086 | lim_sup: 11128.6305 Límites IQR -> lim_inf: -603.1233 | lim_sup: 1336.7940 Límites IQR -> lim_inf: -32.3760 | lim_sup: 53.9600 Límites IQR -> lim_inf: -6060.5760 | lim_sup: 16436.3939 Límites IQR -> lim_inf: -322.8650 | lim_sup: 700.0950 Límites IQR -> lim_inf: -32.3730 | lim_sup: 53.9550 Límites IQR -> lim_inf: -4356.6329 | lim_sup: 11773.4665 Límites IQR -> lim_inf: -636.0932 | lim_sup: 2336.5490 Límites IQR -> lim_inf: -48.4530 | lim_sup: 147.4217 Límites IQR -> lim_inf: -9819.0923 | lim_sup: 24961.2518 Límites IQR -> lim_inf: -866.5000 | lim_sup: 2029.5000 Límites IQR -> lim_inf: -47.4062 | lim_sup: 154.0497 Límites IQR -> lim_inf: -6537.4124 | lim_sup: 17202.5328 Límites IQR -> lim_inf: -291.1000 | lim_sup: 634.5000 Límites IQR -> lim_inf: -188.0070 | lim_sup: 368.9530 Límites IQR -> lim_inf: -8637.9536 | lim_sup: 19038.8377 Límites IQR -> lim_inf: 80.0000 | lim_sup: 80.0000 Límites IQR -> lim_inf: -1363.6110 | lim_sup: 2741.3666 Límites IQR -> lim_inf: -108.0000 | lim_sup: 180.0000 Límites IQR -> lim_inf: -10229.8078 | lim_sup: 24243.8797 Límites IQR -> lim_inf: 18.7700 | lim_sup: 25.5700 Límites IQR -> lim_inf: -260.8513 | lim_sup: 837.9388 Límites IQR -> lim_inf: 71.6650 | lim_sup: 71.6650 Límites IQR -> lim_inf: -4658.8991 | lim_sup: 14887.7319 Límites IQR -> lim_inf: 5204.1303 | lim_sup: 6657.8362 Límites IQR -> lim_inf: -1708.4500 | lim_sup: 5454.5500 Límites IQR -> lim_inf: 94.9400 | lim_sup: 94.9400 Límites IQR -> lim_inf: 260.0000 | lim_sup: 260.0000 Límites IQR -> lim_inf: 240.0000 | lim_sup: 240.0000 Límites IQR -> lim_inf: -44.6375 | lim_sup: 1213.0225 Límites IQR -> lim_inf: 80.1500 | lim_sup: 80.1500 Límites IQR -> lim_inf: 260.0000 | lim_sup: 260.0000 Límites IQR -> lim_inf: 240.0000 | lim_sup: 240.0000 Límites IQR -> lim_inf: -144.2250 | lim_sup: 1059.6350 Límites IQR -> lim_inf: 80.1500 | lim_sup: 80.1500 Límites IQR -> lim_inf: 260.0000 | lim_sup: 260.0000 Límites IQR -> lim_inf: 4.0000 | lim_sup: 308.0000 Límites IQR -> lim_inf: -214.3825 | lim_sup: 692.5975 Límites IQR -> lim_inf: 80.1500 | lim_sup: 80.1500 Límites IQR -> lim_inf: -11115.9455 | lim_sup: 72335.4364 Límites IQR -> lim_inf: 13323.2636 | lim_sup: 13323.2636 ITE2 train enriquecido: (72492, 9) === RESUMEN % ITE2 (2022–2024 vs 2025 YTD) === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable variable cost_float_mod cost_float_mod 84.51 15.49 cost_float_mod_2 cost_float_mod_2 84.11 15.89 cost_float_mod_3 cost_float_mod_3 83.10 16.90 === Muestra detalle ITE2 === ID_BUILDING FM_COST_TYPE variable ratio_mediana ratio_mad \ 0 2 Licencias cost_float_mod inf 1.0 1 2 Licencias cost_float_mod_2 0.183432 0.0 2 2 Licencias cost_float_mod_3 inf 1.0 3 2 Mtto. Contratos cost_float_mod 1.000000 0.0 4 2 Mtto. Contratos cost_float_mod_2 1.000000 0.0 p_value WAPE_naive_anual ratio_anual delta_prop_zeros flag_mediana \ 0 1.000000 1.000000 0.969119 0.885714 1 1 1.000000 0.155000 0.969119 0.342857 0 2 1.000000 1.000000 0.877921 0.885714 1 3 0.037097 1.135495 0.805223 0.611111 1 4 0.037097 1.135495 0.805223 0.611111 1 flag_mad flag_pvalue flag_wape flag_anual flag_sparsidad n_flags \ 0 0 0 1 1 1 4 1 1 0 0 1 1 3 2 0 0 1 1 1 4 3 1 1 1 1 1 6 4 1 1 1 1 1 6 clasificacion_estad_global_train_vs_test 0 CAMBIO 1 CAMBIO 2 CAMBIO 3 CAMBIO 4 CAMBIO
Clasificamos con el criterio de estabilidad el historico del 2022 y 2023 (dejamos fuera el 2021 con un modelo de gestión distinto)¶
# =========================================
# 2) Clasificación 2022–2024 vs 2025 YTD
# - TEST: siempre cost_float_mod real
# - TRAIN: probamos mod, mod_2, mod_3
# =========================================
vars_coste = ["cost_float_mod", "cost_float_mod_2", "cost_float_mod_3"]
resultados = []
pairs_train = df_ite2_train_enr[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_test = df_fd1_v5_ITE2_test[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates()
pairs_both = pairs_train.merge(pairs_test, on=["ID_BUILDING","FM_COST_TYPE"])
for _, row in pairs_both.iterrows():
bid, ctype = row["ID_BUILDING"], row["FM_COST_TYPE"]
g_train = df_ite2_train_enr[(df_ite2_train_enr["ID_BUILDING"]==bid) & (df_ite2_train_enr["FM_COST_TYPE"]==ctype)]
g_test = df_fd1_v5_ITE2_test[(df_fd1_v5_ITE2_test["ID_BUILDING"]==bid) & (df_fd1_v5_ITE2_test["FM_COST_TYPE"]==ctype)]
if g_train.empty or g_test.empty:
continue
s_test = _to_ms(g_test, "cost_float_mod")
s_test = s_test[s_test.index.year == 2025] # 2025 YTD
for var in vars_coste:
s_train = _to_ms(g_train, var)
s_train = s_train[(s_train.index.year >= 2022) & (s_train.index.year <= 2024)]
if len(s_train)==0 or len(s_test)==0:
continue
res = evaluar_estabilidad_robusta(
s_train, s_test,
alpha_pvalue=0.05,
umbral_mediana=0.30,
umbral_mad_hi=1.75,
umbral_wape=0.50,
umbral_anual=0.30,
umbral_delta_zeros=0.30,
regla_mayoria_k=2
)
resultados.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
"variable": var,
**res
})
df_estabilidad_ite2 = pd.DataFrame(resultados)
resumen_ite2 = (
df_estabilidad_ite2.groupby(["variable","clasificacion_estad_global_train_vs_test"])
.size().groupby(level=0).apply(lambda x: 100*x/x.sum())
.unstack(fill_value=0)
).round(2)
print("=== RESUMEN % ITE2 (2022–2024 vs 2025 YTD) ===")
print(resumen_ite2)
print("\n=== Muestra detalle ITE2 ===")
print(df_estabilidad_ite2.head())
=== RESUMEN % ITE2 (2022–2024 vs 2025 YTD) === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable variable cost_float_mod cost_float_mod 84.51 15.49 cost_float_mod_2 cost_float_mod_2 84.11 15.89 cost_float_mod_3 cost_float_mod_3 83.10 16.90 === Muestra detalle ITE2 === ID_BUILDING FM_COST_TYPE variable ratio_mediana ratio_mad \ 0 2 Licencias cost_float_mod inf 1.0 1 2 Licencias cost_float_mod_2 0.183432 0.0 2 2 Licencias cost_float_mod_3 inf 1.0 3 2 Mtto. Contratos cost_float_mod 1.000000 0.0 4 2 Mtto. Contratos cost_float_mod_2 1.000000 0.0 p_value WAPE_naive_anual ratio_anual delta_prop_zeros flag_mediana \ 0 1.000000 1.000000 0.969119 0.885714 1 1 1.000000 0.155000 0.969119 0.342857 0 2 1.000000 1.000000 0.877921 0.885714 1 3 0.037097 1.135495 0.805223 0.611111 1 4 0.037097 1.135495 0.805223 0.611111 1 flag_mad flag_pvalue flag_wape flag_anual flag_sparsidad n_flags \ 0 0 0 1 1 1 4 1 1 0 0 1 1 3 2 0 0 1 1 1 4 3 1 1 1 1 1 6 4 1 1 1 1 1 6 clasificacion_estad_global_train_vs_test 0 CAMBIO 1 CAMBIO 2 CAMBIO 3 CAMBIO 4 CAMBIO
Ahora vamos a profundizar un poco de donde se acometen las estables tanto para la iteración 1 como para la 2.
Necesitamos caracterizar el grupo ESTABLE en función de las variables de contexto que ya tenemos en el dataset.
Recordemos que tus datasets tienen estas columnas:
ID_ORDER
ID_BUILDING
FM_COST_TYPE
MONTH
YEAR
cost_float_mod
SUPPLIER_TYPE_MOD_2
FM_RESPONSIBLE_MOD
TIPO_USO
COUNTRY_CATALOGO
ID_REGION_GRUPO
COUNTRY_DEF
Enfoque para situar los ESTABLES
Enriquecer la clasificación Tenemos ya los DataFrame df_estabilidad para la ITE1, df_estabilidad_rb_22_23 para los años 2022-2023 de la ITE1 y df_estabilidad_ite2 para la ITE2, con las etiquetas ESTABLE / CAMBIO por pareja (ID_BUILDING, FM_COST_TYPE). Lo que hacemos es unirlo con las tablas originales (train o real) para recuperar el resto de atributos.
Análisis de distribución
Por FM_COST_TYPE: ¿qué tipos de gasto son más estables? (ej. ¿Suministros más estables que Mantenimiento?).
Por SUPPLIER_TYPE_MOD_2: ¿los proveedores internos o externos son más estables?
Por TIPO_USO: ¿qué tipo de inmueble (oficinas, salas de juego, talleres) muestra mayor estabilidad?
Por COUNTRY_DEF y ID_REGION_GRUPO: ¿hay diferencias geográficas?
Por FM_RESPONSIBLE_MOD: ¿hay responsables con carteras más estables que otros?
Medidas que podemos calcular
Proporción de ESTABLE vs CAMBIO en cada categoría.
Top categorías con mayor % de ESTABLES (donde la predicción tiene más sentido).
Top categorías con mayor % de CAMBIOS (donde será más difícil usar modelos de series).
Enriquecemos el df_estabilidad con variables de contexto de la pareja.¶
# ==========================================================
# 1) Función para enriquecer clasificación con variables de contexto
# ==========================================================
def enriquecer_con_contexto(df_estabilidad, df_train, nombre_iteracion="ITE"):
# Nos quedamos con una única fila por pareja (ID_BUILDING, FM_COST_TYPE)
base = df_train.drop_duplicates(subset=["ID_BUILDING","FM_COST_TYPE"])
# Unimos
df_enr = df_estabilidad.merge(
base[
[
"ID_BUILDING","FM_COST_TYPE",
"SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD",
"TIPO_USO","COUNTRY_DEF","ID_REGION_GRUPO"
]
],
on=["ID_BUILDING","FM_COST_TYPE"],
how="left"
)
print(f"[OK] Enriquecido {nombre_iteracion}: {df_enr.shape}")
return df_enr
# Ejemplo con ITE1 (si usas el panel 2022–2023 como referencia)
df_estab_ite1_enr = enriquecer_con_contexto(df_estabilidad_rb_22_23, df_fd1_v5_ITE1_train, "ITE1")
# Ejemplo con ITE2
df_estab_ite2_enr = enriquecer_con_contexto(df_estabilidad_ite2, df_fd1_v5_ITE2_train, "ITE2")
[OK] Enriquecido ITE1: (6600, 22) [OK] Enriquecido ITE2: (6798, 22)
Generamos las distribuciones del % estabilidad por variable de contexto.¶
# ==========================================================
# 2) Función de análisis de distribución por variable de contexto
# ==========================================================
def distribucion_por_contexto(df_enr, variable, nombre_iteracion="ITE"):
tabla = (
df_enr.groupby([variable,"clasificacion_estad_global_train_vs_test"])
.size()
.groupby(level=0)
.apply(lambda x: 100 * x / x.sum())
.unstack(fill_value=0)
.sort_values("ESTABLE", ascending=False)
)
print(f"\n=== Distribución {nombre_iteracion} por {variable} ===")
return tabla.round(2)
# Ejemplo para ITE1 enriquecido
dist_tipo_gasto_ite1 = distribucion_por_contexto(df_estab_ite1_enr, "FM_COST_TYPE", "ITE1")
dist_proveedor_ite1 = distribucion_por_contexto(df_estab_ite1_enr, "SUPPLIER_TYPE_MOD_2", "ITE1")
dist_uso_ite1 = distribucion_por_contexto(df_estab_ite1_enr, "TIPO_USO", "ITE1")
dist_pais_ite1 = distribucion_por_contexto(df_estab_ite1_enr, "COUNTRY_DEF", "ITE1")
dist_region_ite1 = distribucion_por_contexto(df_estab_ite1_enr, "ID_REGION_GRUPO", "ITE1")
dist_responsable_ite1= distribucion_por_contexto(df_estab_ite1_enr, "FM_RESPONSIBLE_MOD", "ITE1")
# Ejemplo para ITE2 enriquecido
dist_tipo_gasto_ite2 = distribucion_por_contexto(df_estab_ite2_enr, "FM_COST_TYPE", "ITE2")
dist_proveedor_ite2 = distribucion_por_contexto(df_estab_ite2_enr, "SUPPLIER_TYPE_MOD_2", "ITE2")
dist_uso_ite2 = distribucion_por_contexto(df_estab_ite2_enr, "TIPO_USO", "ITE2")
dist_pais_ite2 = distribucion_por_contexto(df_estab_ite2_enr, "COUNTRY_DEF", "ITE2")
dist_region_ite2 = distribucion_por_contexto(df_estab_ite2_enr, "ID_REGION_GRUPO", "ITE2")
dist_responsable_ite2= distribucion_por_contexto(df_estab_ite2_enr, "FM_RESPONSIBLE_MOD", "ITE2")
=== Distribución ITE1 por FM_COST_TYPE === === Distribución ITE1 por SUPPLIER_TYPE_MOD_2 === === Distribución ITE1 por TIPO_USO === === Distribución ITE1 por COUNTRY_DEF === === Distribución ITE1 por ID_REGION_GRUPO === === Distribución ITE1 por FM_RESPONSIBLE_MOD === === Distribución ITE2 por FM_COST_TYPE === === Distribución ITE2 por SUPPLIER_TYPE_MOD_2 === === Distribución ITE2 por TIPO_USO === === Distribución ITE2 por COUNTRY_DEF === === Distribución ITE2 por ID_REGION_GRUPO === === Distribución ITE2 por FM_RESPONSIBLE_MOD ===
Visualización gráfica¶
def plot_distribucion(df_tabla, variable, nombre_iteracion="ITE"):
plt.figure(figsize=(8,5))
df_tabla["ESTABLE"].plot(kind="barh", color="steelblue")
plt.xlabel("% ESTABLE")
plt.title(f"{nombre_iteracion} - % ESTABLE por {variable}")
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()
# Ejemplos con FM_COST_TYPE
plot_distribucion(dist_tipo_gasto_ite1, "FM_COST_TYPE", "ITE1")
plot_distribucion(dist_tipo_gasto_ite2, "FM_COST_TYPE", "ITE2")
# Ejemplos con SUPPLIER_TYPE_MOD_2
plot_distribucion(dist_proveedor_ite1, "SUPPLIER_TYPE_MOD_2", "ITE1")
plot_distribucion(dist_proveedor_ite2, "SUPPLIER_TYPE_MOD_2", "ITE2")
# Ejemplos con TIPO_USO
plot_distribucion(dist_uso_ite1, "TIPO_USO", "ITE1")
plot_distribucion(dist_uso_ite2, "TIPO_USO", "ITE2")
# Puedes repetir lo mismo con COUNTRY_DEF, ID_REGION_GRUPO y FM_RESPONSIBLE_MOD
Desglose resultados en formato tabular (% estable)¶
Ahora lo desglosamos en formato tabular para cada variable coste cruzada con la variable de contexto y midiendo el % estables.
def distribucion_contexto_por_variable(df_estabilidad, df_train, contexto, nombre_iteracion="ITE"):
"""
Calcula % ESTABLE/CAMBIO por categoría de una variable de contexto,
separado por 'variable' (cost_float_mod, cost_float_mod_2, cost_float_mod_3).
Evitamos columnas duplicadas cuando el contexto coincide con una clave del join.
"""
# Claves de pareja
keys = ["ID_BUILDING", "FM_COST_TYPE"]
# Base de contexto: una fila por pareja
base = df_train.drop_duplicates(subset=keys)
# Armamos la lista de columnas a traer del train
cols_merge = keys.copy()
if contexto not in keys:
cols_merge.append(contexto)
# Hacemos el merge (solo añadimos 'contexto' si no es key)
df_enr = df_estabilidad.merge(
base[cols_merge],
on=keys,
how="left",
suffixes=("", "_ctx")
)
# Si el contexto es una key (p.ej. "FM_COST_TYPE"), usamos la propia columna ya presente;
# si no lo es, usamos la columna traída del train.
if contexto in keys:
ctx_col = contexto # ya existe en df_estabilidad
else:
ctx_col = contexto
# Por seguridad: eliminamos posibles columnas duplicadas de nombre
df_enr = df_enr.loc[:, ~df_enr.columns.duplicated()]
# Tabla de distribución (%)
tabla = (
df_enr.groupby(["variable", ctx_col, "clasificacion_estad_global_train_vs_test"])
.size()
.groupby(level=[0, 1])
.apply(lambda x: 100 * x / x.sum())
.unstack(fill_value=0)
.round(2)
)
print(f"\n=== {nombre_iteracion} — Distribución por {contexto} ===")
return tabla
# ====== ITE2 ======
tablas_contexto_ite2 = {}
for contexto in ["FM_COST_TYPE", "TIPO_USO", "COUNTRY_DEF", "ID_REGION_GRUPO", "FM_RESPONSIBLE_MOD"]:
tablas_contexto_ite2[contexto] = distribucion_contexto_por_variable(
df_estabilidad_ite2, df_fd1_v5_ITE2_train, contexto, "ITE2"
)
print(tablas_contexto_ite2[contexto].to_string())
# ====== ITE1 (ajusta el DF de estabilidad que estés usando: df_estabilidad_rb_22_23 o similar) ======
tablas_contexto_ite1 = {}
for contexto in ["FM_COST_TYPE", "TIPO_USO", "COUNTRY_DEF", "ID_REGION_GRUPO", "FM_RESPONSIBLE_MOD"]:
tablas_contexto_ite1[contexto] = distribucion_contexto_por_variable(
df_estabilidad_rb_22_23, df_fd1_v5_ITE1_train, contexto, "ITE1"
)
print(tablas_contexto_ite1[contexto].to_string())
=== ITE2 — Distribución por FM_COST_TYPE === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable FM_COST_TYPE variable FM_COST_TYPE cost_float_mod Eficiencia Energética cost_float_mod Eficiencia Energética 80.00 20.00 Licencias cost_float_mod Licencias 100.00 0.00 Mtto. Contratos cost_float_mod Mtto. Contratos 86.96 13.04 Mtto. Correctivo cost_float_mod Mtto. Correctivo 85.11 14.89 Obras cost_float_mod Obras 95.00 5.00 Servicios Ctto. cost_float_mod Servicios Ctto. 81.28 18.72 Servicios Extra cost_float_mod Servicios Extra 95.48 4.52 Suministros cost_float_mod Suministros 71.96 28.04 cost_float_mod_2 Eficiencia Energética cost_float_mod_2 Eficiencia Energética 82.22 17.78 Licencias cost_float_mod_2 Licencias 99.39 0.61 Mtto. Contratos cost_float_mod_2 Mtto. Contratos 83.70 16.30 Mtto. Correctivo cost_float_mod_2 Mtto. Correctivo 81.56 18.44 Obras cost_float_mod_2 Obras 95.00 5.00 Servicios Ctto. cost_float_mod_2 Servicios Ctto. 80.73 19.27 Servicios Extra cost_float_mod_2 Servicios Extra 95.48 4.52 Suministros cost_float_mod_2 Suministros 76.08 23.92 cost_float_mod_3 Eficiencia Energética cost_float_mod_3 Eficiencia Energética 68.89 31.11 Licencias cost_float_mod_3 Licencias 100.00 0.00 Mtto. Contratos cost_float_mod_3 Mtto. Contratos 86.41 13.59 Mtto. Correctivo cost_float_mod_3 Mtto. Correctivo 83.11 16.89 Obras cost_float_mod_3 Obras 95.00 5.00 Servicios Ctto. cost_float_mod_3 Servicios Ctto. 82.40 17.60 Servicios Extra cost_float_mod_3 Servicios Extra 94.58 5.42 Suministros cost_float_mod_3 Suministros 68.63 31.37 === ITE2 — Distribución por TIPO_USO === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable TIPO_USO variable TIPO_USO cost_float_mod Almacén/Bodega cost_float_mod Almacén/Bodega 86.96 13.04 Bar/Restaurante cost_float_mod Bar/Restaurante 80.00 20.00 Bingo cost_float_mod Bingo 84.96 15.04 Casino Electrónico cost_float_mod Casino Electrónico 71.48 28.52 Casino Tradicional cost_float_mod Casino Tradicional 72.66 27.34 Delegación cost_float_mod Delegación 82.51 17.49 Hotel cost_float_mod Hotel 50.00 50.00 Industrial cost_float_mod Industrial 85.71 14.29 Local Apuestas cost_float_mod Local Apuestas 88.24 11.76 Oficinas cost_float_mod Oficinas 78.79 21.21 Parking cost_float_mod Parking 100.00 0.00 Salón de Juego cost_float_mod Salón de Juego 96.40 3.60 Sin actividad cost_float_mod Sin actividad 90.00 10.00 Subarrendado cost_float_mod Subarrendado 100.00 0.00 cost_float_mod_2 Almacén/Bodega cost_float_mod_2 Almacén/Bodega 80.43 19.57 Bar/Restaurante cost_float_mod_2 Bar/Restaurante 73.33 26.67 Bingo cost_float_mod_2 Bingo 84.07 15.93 Casino Electrónico cost_float_mod_2 Casino Electrónico 73.89 26.11 Casino Tradicional cost_float_mod_2 Casino Tradicional 76.17 23.83 Delegación cost_float_mod_2 Delegación 76.50 23.50 Hotel cost_float_mod_2 Hotel 50.00 50.00 Industrial cost_float_mod_2 Industrial 85.71 14.29 Local Apuestas cost_float_mod_2 Local Apuestas 82.35 17.65 Oficinas cost_float_mod_2 Oficinas 74.24 25.76 Parking cost_float_mod_2 Parking 100.00 0.00 Salón de Juego cost_float_mod_2 Salón de Juego 95.28 4.72 Sin actividad cost_float_mod_2 Sin actividad 90.00 10.00 Subarrendado cost_float_mod_2 Subarrendado 100.00 0.00 cost_float_mod_3 Almacén/Bodega cost_float_mod_3 Almacén/Bodega 86.96 13.04 Bar/Restaurante cost_float_mod_3 Bar/Restaurante 80.00 20.00 Bingo cost_float_mod_3 Bingo 85.84 14.16 Casino Electrónico cost_float_mod_3 Casino Electrónico 67.78 32.22 Casino Tradicional cost_float_mod_3 Casino Tradicional 71.09 28.91 Delegación cost_float_mod_3 Delegación 81.97 18.03 Hotel cost_float_mod_3 Hotel 50.00 50.00 Industrial cost_float_mod_3 Industrial 71.43 28.57 Local Apuestas cost_float_mod_3 Local Apuestas 88.24 11.76 Oficinas cost_float_mod_3 Oficinas 78.79 21.21 Parking cost_float_mod_3 Parking 100.00 0.00 Salón de Juego cost_float_mod_3 Salón de Juego 95.51 4.49 Sin actividad cost_float_mod_3 Sin actividad 90.00 10.00 Subarrendado cost_float_mod_3 Subarrendado 100.00 0.00 === ITE2 — Distribución por COUNTRY_DEF === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable COUNTRY_DEF variable COUNTRY_DEF cost_float_mod Colombia cost_float_mod Colombia 75.70 24.30 Costa Rica cost_float_mod Costa Rica 76.60 23.40 España cost_float_mod España 91.61 8.39 México cost_float_mod México 79.69 20.31 Panamá cost_float_mod Panamá 63.11 36.89 Perú cost_float_mod Perú 70.30 29.70 República Dominicana cost_float_mod República Dominicana 63.16 36.84 cost_float_mod_2 Colombia cost_float_mod_2 Colombia 87.65 12.35 Costa Rica cost_float_mod_2 Costa Rica 70.21 29.79 España cost_float_mod_2 España 89.38 10.62 México cost_float_mod_2 México 78.65 21.35 Panamá cost_float_mod_2 Panamá 61.33 38.67 Perú cost_float_mod_2 Perú 73.27 26.73 República Dominicana cost_float_mod_2 República Dominicana 57.89 42.11 cost_float_mod_3 Colombia cost_float_mod_3 Colombia 70.92 29.08 Costa Rica cost_float_mod_3 Costa Rica 76.60 23.40 España cost_float_mod_3 España 91.13 8.87 México cost_float_mod_3 México 78.12 21.88 Panamá cost_float_mod_3 Panamá 59.11 40.89 Perú cost_float_mod_3 Perú 69.31 30.69 República Dominicana cost_float_mod_3 República Dominicana 63.16 36.84 === ITE2 — Distribución por ID_REGION_GRUPO === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable ID_REGION_GRUPO variable ID_REGION_GRUPO cost_float_mod 100 cost_float_mod 100 66.67 33.33 1000005 cost_float_mod 1000005 66.67 33.33 1000010 cost_float_mod 1000010 80.00 20.00 1000015 cost_float_mod 1000015 83.33 16.67 1000016 cost_float_mod 1000016 93.75 6.25 1000017 cost_float_mod 1000017 83.33 16.67 1000018 cost_float_mod 1000018 83.33 16.67 1000019 cost_float_mod 1000019 61.54 38.46 1000020 cost_float_mod 1000020 73.33 26.67 1000021 cost_float_mod 1000021 83.33 16.67 1000022 cost_float_mod 1000022 66.10 33.90 1000023 cost_float_mod 1000023 76.19 23.81 1000030 cost_float_mod 1000030 88.89 11.11 1000031 cost_float_mod 1000031 100.00 0.00 1000038 cost_float_mod 1000038 100.00 0.00 1000039 cost_float_mod 1000039 63.16 36.84 1000054 cost_float_mod 1000054 81.82 18.18 1000058 cost_float_mod 1000058 63.64 36.36 1000096 cost_float_mod 1000096 76.92 23.08 1000118 cost_float_mod 1000118 85.71 14.29 103 cost_float_mod 103 74.67 25.33 104 cost_float_mod 104 66.67 33.33 105 cost_float_mod 105 86.67 13.33 106 cost_float_mod 106 83.33 16.67 107 cost_float_mod 107 33.33 66.67 108 cost_float_mod 108 66.67 33.33 109 cost_float_mod 109 100.00 0.00 11 cost_float_mod 11 94.44 5.56 112 cost_float_mod 112 75.00 25.00 113 cost_float_mod 113 77.78 22.22 115 cost_float_mod 115 100.00 0.00 117 cost_float_mod 117 51.61 48.39 12 cost_float_mod 12 83.33 16.67 15 cost_float_mod 15 97.37 2.63 17 cost_float_mod 17 84.51 15.49 18 cost_float_mod 18 95.00 5.00 2 cost_float_mod 2 90.52 9.48 21 cost_float_mod 21 92.45 7.55 27 cost_float_mod 27 96.97 3.03 3 cost_float_mod 3 91.43 8.57 32 cost_float_mod 32 94.24 5.76 36 cost_float_mod 36 92.31 7.69 4 cost_float_mod 4 89.29 10.71 48 cost_float_mod 48 84.44 15.56 5 cost_float_mod 5 93.88 6.12 6 cost_float_mod 6 98.59 1.41 7 cost_float_mod 7 96.55 3.45 8 cost_float_mod 8 93.60 6.40 94 cost_float_mod 94 61.88 38.12 96 cost_float_mod 96 72.22 27.78 Otros_Colombia cost_float_mod Otros_Colombia 73.33 26.67 Otros_España cost_float_mod Otros_España 89.24 10.76 Otros_México cost_float_mod Otros_México 77.14 22.86 Otros_Panamá cost_float_mod Otros_Panamá 54.17 45.83 Otros_Perú cost_float_mod Otros_Perú 76.19 23.81 cost_float_mod_2 100 cost_float_mod_2 100 50.00 50.00 1000005 cost_float_mod_2 1000005 66.67 33.33 1000010 cost_float_mod_2 1000010 70.00 30.00 1000015 cost_float_mod_2 1000015 83.33 16.67 1000016 cost_float_mod_2 1000016 75.00 25.00 1000017 cost_float_mod_2 1000017 83.33 16.67 1000018 cost_float_mod_2 1000018 83.33 16.67 1000019 cost_float_mod_2 1000019 69.23 30.77 1000020 cost_float_mod_2 1000020 73.33 26.67 1000021 cost_float_mod_2 1000021 83.33 16.67 1000022 cost_float_mod_2 1000022 67.80 32.20 1000023 cost_float_mod_2 1000023 76.19 23.81 1000030 cost_float_mod_2 1000030 88.89 11.11 1000031 cost_float_mod_2 1000031 100.00 0.00 1000038 cost_float_mod_2 1000038 80.00 20.00 1000039 cost_float_mod_2 1000039 57.89 42.11 1000054 cost_float_mod_2 1000054 81.82 18.18 1000058 cost_float_mod_2 1000058 63.64 36.36 1000096 cost_float_mod_2 1000096 84.62 15.38 1000118 cost_float_mod_2 1000118 85.71 14.29 103 cost_float_mod_2 103 86.67 13.33 104 cost_float_mod_2 104 83.33 16.67 105 cost_float_mod_2 105 95.00 5.00 106 cost_float_mod_2 106 100.00 0.00 107 cost_float_mod_2 107 66.67 33.33 108 cost_float_mod_2 108 100.00 0.00 109 cost_float_mod_2 109 100.00 0.00 11 cost_float_mod_2 11 94.44 5.56 112 cost_float_mod_2 112 91.67 8.33 113 cost_float_mod_2 113 88.89 11.11 115 cost_float_mod_2 115 100.00 0.00 117 cost_float_mod_2 117 74.19 25.81 12 cost_float_mod_2 12 80.00 20.00 15 cost_float_mod_2 15 94.74 5.26 17 cost_float_mod_2 17 85.92 14.08 18 cost_float_mod_2 18 92.50 7.50 2 cost_float_mod_2 2 86.64 13.36 21 cost_float_mod_2 21 94.34 5.66 27 cost_float_mod_2 27 93.94 6.06 3 cost_float_mod_2 3 94.29 5.71 32 cost_float_mod_2 32 92.81 7.19 36 cost_float_mod_2 36 88.46 11.54 4 cost_float_mod_2 4 89.29 10.71 48 cost_float_mod_2 48 75.56 24.44 5 cost_float_mod_2 5 93.88 6.12 6 cost_float_mod_2 6 95.77 4.23 7 cost_float_mod_2 7 100.00 0.00 8 cost_float_mod_2 8 91.20 8.80 94 cost_float_mod_2 94 61.25 38.75 96 cost_float_mod_2 96 66.67 33.33 Otros_Colombia cost_float_mod_2 Otros_Colombia 80.00 20.00 Otros_España cost_float_mod_2 Otros_España 86.05 13.95 Otros_México cost_float_mod_2 Otros_México 77.14 22.86 Otros_Panamá cost_float_mod_2 Otros_Panamá 54.17 45.83 Otros_Perú cost_float_mod_2 Otros_Perú 85.71 14.29 cost_float_mod_3 100 cost_float_mod_3 100 66.67 33.33 1000005 cost_float_mod_3 1000005 66.67 33.33 1000010 cost_float_mod_3 1000010 80.00 20.00 1000015 cost_float_mod_3 1000015 83.33 16.67 1000016 cost_float_mod_3 1000016 81.25 18.75 1000017 cost_float_mod_3 1000017 83.33 16.67 1000018 cost_float_mod_3 1000018 83.33 16.67 1000019 cost_float_mod_3 1000019 69.23 30.77 1000020 cost_float_mod_3 1000020 66.67 33.33 1000021 cost_float_mod_3 1000021 66.67 33.33 1000022 cost_float_mod_3 1000022 64.41 35.59 1000023 cost_float_mod_3 1000023 76.19 23.81 1000030 cost_float_mod_3 1000030 88.89 11.11 1000031 cost_float_mod_3 1000031 100.00 0.00 1000038 cost_float_mod_3 1000038 100.00 0.00 1000039 cost_float_mod_3 1000039 63.16 36.84 1000054 cost_float_mod_3 1000054 81.82 18.18 1000058 cost_float_mod_3 1000058 72.73 27.27 1000096 cost_float_mod_3 1000096 69.23 30.77 1000118 cost_float_mod_3 1000118 85.71 14.29 103 cost_float_mod_3 103 70.67 29.33 104 cost_float_mod_3 104 66.67 33.33 105 cost_float_mod_3 105 78.33 21.67 106 cost_float_mod_3 106 83.33 16.67 107 cost_float_mod_3 107 33.33 66.67 108 cost_float_mod_3 108 66.67 33.33 109 cost_float_mod_3 109 83.33 16.67 11 cost_float_mod_3 11 94.44 5.56 112 cost_float_mod_3 112 66.67 33.33 113 cost_float_mod_3 113 77.78 22.22 115 cost_float_mod_3 115 83.33 16.67 117 cost_float_mod_3 117 48.39 51.61 12 cost_float_mod_3 12 86.67 13.33 15 cost_float_mod_3 15 97.37 2.63 17 cost_float_mod_3 17 83.10 16.90 18 cost_float_mod_3 18 95.00 5.00 2 cost_float_mod_3 2 90.52 9.48 21 cost_float_mod_3 21 90.57 9.43 27 cost_float_mod_3 27 96.97 3.03 3 cost_float_mod_3 3 94.29 5.71 32 cost_float_mod_3 32 92.81 7.19 36 cost_float_mod_3 36 92.31 7.69 4 cost_float_mod_3 4 89.29 10.71 48 cost_float_mod_3 48 84.44 15.56 5 cost_float_mod_3 5 93.88 6.12 6 cost_float_mod_3 6 98.59 1.41 7 cost_float_mod_3 7 96.55 3.45 8 cost_float_mod_3 8 92.80 7.20 94 cost_float_mod_3 94 56.88 43.12 96 cost_float_mod_3 96 72.22 27.78 Otros_Colombia cost_float_mod_3 Otros_Colombia 80.00 20.00 Otros_España cost_float_mod_3 Otros_España 88.08 11.92 Otros_México cost_float_mod_3 Otros_México 75.71 24.29 Otros_Panamá cost_float_mod_3 Otros_Panamá 50.00 50.00 Otros_Perú cost_float_mod_3 Otros_Perú 76.19 23.81 === ITE2 — Distribución por FM_RESPONSIBLE_MOD === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable FM_RESPONSIBLE_MOD variable FM_RESPONSIBLE_MOD cost_float_mod Eficiencia Energética cost_float_mod Eficiencia Energética 72.61 27.39 Gestión Espacios cost_float_mod Gestión Espacios 100.00 0.00 Licencias cost_float_mod Licencias 100.00 0.00 Mantenimiento cost_float_mod Mantenimiento 86.94 13.06 Obras Proyectos cost_float_mod Obras Proyectos 93.55 6.45 cost_float_mod_2 Eficiencia Energética cost_float_mod_2 Eficiencia Energética 76.58 23.42 Gestión Espacios cost_float_mod_2 Gestión Espacios 100.00 0.00 Licencias cost_float_mod_2 Licencias 99.39 0.61 Mantenimiento cost_float_mod_2 Mantenimiento 84.95 15.05 Obras Proyectos cost_float_mod_2 Obras Proyectos 93.55 6.45 cost_float_mod_3 Eficiencia Energética cost_float_mod_3 Eficiencia Energética 68.65 31.35 Gestión Espacios cost_float_mod_3 Gestión Espacios 100.00 0.00 Licencias cost_float_mod_3 Licencias 100.00 0.00 Mantenimiento cost_float_mod_3 Mantenimiento 86.27 13.73 Obras Proyectos cost_float_mod_3 Obras Proyectos 93.55 6.45 === ITE1 — Distribución por FM_COST_TYPE === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable FM_COST_TYPE variable FM_COST_TYPE cost_float_mod Eficiencia Energética cost_float_mod Eficiencia Energética 56.25 43.75 Licencias cost_float_mod Licencias 98.31 1.69 Mtto. Contratos cost_float_mod Mtto. Contratos 84.44 15.56 Mtto. Correctivo cost_float_mod Mtto. Correctivo 78.67 21.33 Obras cost_float_mod Obras 93.55 6.45 Servicios Ctto. cost_float_mod Servicios Ctto. 83.18 16.82 Servicios Extra cost_float_mod Servicios Extra 90.54 9.46 Suministros cost_float_mod Suministros 82.37 17.63 cost_float_mod_2 Eficiencia Energética cost_float_mod_2 Eficiencia Energética 56.25 43.75 Licencias cost_float_mod_2 Licencias 98.31 1.69 Mtto. Contratos cost_float_mod_2 Mtto. Contratos 84.17 15.83 Mtto. Correctivo cost_float_mod_2 Mtto. Correctivo 77.52 22.48 Obras cost_float_mod_2 Obras 93.55 6.45 Servicios Ctto. cost_float_mod_2 Servicios Ctto. 81.38 18.62 Servicios Extra cost_float_mod_2 Servicios Extra 91.40 8.60 Suministros cost_float_mod_2 Suministros 82.80 17.20 cost_float_mod_3 Eficiencia Energética cost_float_mod_3 Eficiencia Energética 56.25 43.75 Licencias cost_float_mod_3 Licencias 98.31 1.69 Mtto. Contratos cost_float_mod_3 Mtto. Contratos 85.56 14.44 Mtto. Correctivo cost_float_mod_3 Mtto. Correctivo 80.05 19.95 Obras cost_float_mod_3 Obras 93.55 6.45 Servicios Ctto. cost_float_mod_3 Servicios Ctto. 84.08 15.92 Servicios Extra cost_float_mod_3 Servicios Extra 90.83 9.17 Suministros cost_float_mod_3 Suministros 81.72 18.28 === ITE1 — Distribución por TIPO_USO === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable TIPO_USO variable TIPO_USO cost_float_mod Almacén/Bodega cost_float_mod Almacén/Bodega 88.89 11.11 Bar/Restaurante cost_float_mod Bar/Restaurante 75.00 25.00 Bingo cost_float_mod Bingo 85.06 14.94 Casino Electrónico cost_float_mod Casino Electrónico 70.83 29.17 Casino Tradicional cost_float_mod Casino Tradicional 62.34 37.66 Delegación cost_float_mod Delegación 91.53 8.47 Hotel cost_float_mod Hotel 42.86 57.14 Industrial cost_float_mod Industrial 87.50 12.50 Local Apuestas cost_float_mod Local Apuestas 85.71 14.29 Oficinas cost_float_mod Oficinas 83.87 16.13 Parking cost_float_mod Parking 66.67 33.33 Salón de Juego cost_float_mod Salón de Juego 96.86 3.14 Sin actividad cost_float_mod Sin actividad 92.31 7.69 Subarrendado cost_float_mod Subarrendado 100.00 0.00 cost_float_mod_2 Almacén/Bodega cost_float_mod_2 Almacén/Bodega 88.89 11.11 Bar/Restaurante cost_float_mod_2 Bar/Restaurante 75.00 25.00 Bingo cost_float_mod_2 Bingo 84.23 15.77 Casino Electrónico cost_float_mod_2 Casino Electrónico 71.98 28.02 Casino Tradicional cost_float_mod_2 Casino Tradicional 60.67 39.33 Delegación cost_float_mod_2 Delegación 90.40 9.60 Hotel cost_float_mod_2 Hotel 57.14 42.86 Industrial cost_float_mod_2 Industrial 75.00 25.00 Local Apuestas cost_float_mod_2 Local Apuestas 85.71 14.29 Oficinas cost_float_mod_2 Oficinas 79.03 20.97 Parking cost_float_mod_2 Parking 66.67 33.33 Salón de Juego cost_float_mod_2 Salón de Juego 96.63 3.37 Sin actividad cost_float_mod_2 Sin actividad 92.31 7.69 Subarrendado cost_float_mod_2 Subarrendado 100.00 0.00 cost_float_mod_3 Almacén/Bodega cost_float_mod_3 Almacén/Bodega 88.89 11.11 Bar/Restaurante cost_float_mod_3 Bar/Restaurante 75.00 25.00 Bingo cost_float_mod_3 Bingo 86.72 13.28 Casino Electrónico cost_float_mod_3 Casino Electrónico 71.59 28.41 Casino Tradicional cost_float_mod_3 Casino Tradicional 63.18 36.82 Delegación cost_float_mod_3 Delegación 91.53 8.47 Hotel cost_float_mod_3 Hotel 42.86 57.14 Industrial cost_float_mod_3 Industrial 100.00 0.00 Local Apuestas cost_float_mod_3 Local Apuestas 78.57 21.43 Oficinas cost_float_mod_3 Oficinas 85.48 14.52 Parking cost_float_mod_3 Parking 66.67 33.33 Salón de Juego cost_float_mod_3 Salón de Juego 96.98 3.02 Sin actividad cost_float_mod_3 Sin actividad 84.62 15.38 Subarrendado cost_float_mod_3 Subarrendado 100.00 0.00 === ITE1 — Distribución por COUNTRY_DEF === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable COUNTRY_DEF variable COUNTRY_DEF cost_float_mod Colombia cost_float_mod Colombia 81.25 18.75 Costa Rica cost_float_mod Costa Rica 53.85 46.15 España cost_float_mod España 93.09 6.91 México cost_float_mod México 74.86 25.14 Panamá cost_float_mod Panamá 52.49 47.51 Perú cost_float_mod Perú 78.30 21.70 República Dominicana cost_float_mod República Dominicana 34.78 65.22 cost_float_mod_2 Colombia cost_float_mod_2 Colombia 83.93 16.07 Costa Rica cost_float_mod_2 Costa Rica 51.28 48.72 España cost_float_mod_2 España 92.38 7.62 México cost_float_mod_2 México 74.32 25.68 Panamá cost_float_mod_2 Panamá 52.04 47.96 Perú cost_float_mod_2 Perú 76.42 23.58 República Dominicana cost_float_mod_2 República Dominicana 43.48 56.52 cost_float_mod_3 Colombia cost_float_mod_3 Colombia 82.14 17.86 Costa Rica cost_float_mod_3 Costa Rica 53.85 46.15 España cost_float_mod_3 España 93.45 6.55 México cost_float_mod_3 México 74.86 25.14 Panamá cost_float_mod_3 Panamá 52.94 47.06 Perú cost_float_mod_3 Perú 80.19 19.81 República Dominicana cost_float_mod_3 República Dominicana 39.13 60.87 === ITE1 — Distribución por ID_REGION_GRUPO === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable ID_REGION_GRUPO variable ID_REGION_GRUPO cost_float_mod 100 cost_float_mod 100 50.00 50.00 1000005 cost_float_mod 1000005 54.55 45.45 1000010 cost_float_mod 1000010 66.67 33.33 1000015 cost_float_mod 1000015 66.67 33.33 1000016 cost_float_mod 1000016 100.00 0.00 1000017 cost_float_mod 1000017 83.33 16.67 1000018 cost_float_mod 1000018 83.33 16.67 1000019 cost_float_mod 1000019 50.00 50.00 1000020 cost_float_mod 1000020 72.22 27.78 1000021 cost_float_mod 1000021 66.67 33.33 1000022 cost_float_mod 1000022 76.19 23.81 1000023 cost_float_mod 1000023 77.27 22.73 1000030 cost_float_mod 1000030 77.78 22.22 1000031 cost_float_mod 1000031 66.67 33.33 1000038 cost_float_mod 1000038 37.50 62.50 1000039 cost_float_mod 1000039 34.78 65.22 1000054 cost_float_mod 1000054 50.00 50.00 1000058 cost_float_mod 1000058 83.33 16.67 1000096 cost_float_mod 1000096 61.54 38.46 1000118 cost_float_mod 1000118 42.86 57.14 103 cost_float_mod 103 81.16 18.84 104 cost_float_mod 104 66.67 33.33 105 cost_float_mod 105 88.33 11.67 106 cost_float_mod 106 66.67 33.33 107 cost_float_mod 107 33.33 66.67 108 cost_float_mod 108 66.67 33.33 109 cost_float_mod 109 83.33 16.67 11 cost_float_mod 11 95.31 4.69 112 cost_float_mod 112 88.89 11.11 113 cost_float_mod 113 88.89 11.11 115 cost_float_mod 115 83.33 16.67 117 cost_float_mod 117 72.41 27.59 12 cost_float_mod 12 77.78 22.22 15 cost_float_mod 15 95.12 4.88 17 cost_float_mod 17 85.07 14.93 18 cost_float_mod 18 97.67 2.33 2 cost_float_mod 2 88.99 11.01 21 cost_float_mod 21 95.38 4.62 27 cost_float_mod 27 100.00 0.00 3 cost_float_mod 3 93.94 6.06 32 cost_float_mod 32 92.80 7.20 36 cost_float_mod 36 100.00 0.00 4 cost_float_mod 4 91.30 8.70 48 cost_float_mod 48 88.57 11.43 5 cost_float_mod 5 98.15 1.85 6 cost_float_mod 6 95.00 5.00 7 cost_float_mod 7 100.00 0.00 8 cost_float_mod 8 96.38 3.62 94 cost_float_mod 94 54.14 45.86 96 cost_float_mod 96 50.00 50.00 Otros_Colombia cost_float_mod Otros_Colombia 100.00 0.00 Otros_España cost_float_mod Otros_España 93.56 6.44 Otros_México cost_float_mod Otros_México 74.60 25.40 Otros_Panamá cost_float_mod Otros_Panamá 45.83 54.17 Otros_Perú cost_float_mod Otros_Perú 85.71 14.29 cost_float_mod_2 100 cost_float_mod_2 100 58.33 41.67 1000005 cost_float_mod_2 1000005 50.00 50.00 1000010 cost_float_mod_2 1000010 66.67 33.33 1000015 cost_float_mod_2 1000015 66.67 33.33 1000016 cost_float_mod_2 1000016 100.00 0.00 1000017 cost_float_mod_2 1000017 83.33 16.67 1000018 cost_float_mod_2 1000018 83.33 16.67 1000019 cost_float_mod_2 1000019 50.00 50.00 1000020 cost_float_mod_2 1000020 66.67 33.33 1000021 cost_float_mod_2 1000021 66.67 33.33 1000022 cost_float_mod_2 1000022 76.19 23.81 1000023 cost_float_mod_2 1000023 72.73 27.27 1000030 cost_float_mod_2 1000030 77.78 22.22 1000031 cost_float_mod_2 1000031 66.67 33.33 1000038 cost_float_mod_2 1000038 37.50 62.50 1000039 cost_float_mod_2 1000039 43.48 56.52 1000054 cost_float_mod_2 1000054 50.00 50.00 1000058 cost_float_mod_2 1000058 83.33 16.67 1000096 cost_float_mod_2 1000096 84.62 15.38 1000118 cost_float_mod_2 1000118 42.86 57.14 103 cost_float_mod_2 103 84.06 15.94 104 cost_float_mod_2 104 66.67 33.33 105 cost_float_mod_2 105 88.33 11.67 106 cost_float_mod_2 106 66.67 33.33 107 cost_float_mod_2 107 33.33 66.67 108 cost_float_mod_2 108 66.67 33.33 109 cost_float_mod_2 109 83.33 16.67 11 cost_float_mod_2 11 95.31 4.69 112 cost_float_mod_2 112 88.89 11.11 113 cost_float_mod_2 113 100.00 0.00 115 cost_float_mod_2 115 83.33 16.67 117 cost_float_mod_2 117 72.41 27.59 12 cost_float_mod_2 12 77.78 22.22 15 cost_float_mod_2 15 95.12 4.88 17 cost_float_mod_2 17 79.10 20.90 18 cost_float_mod_2 18 97.67 2.33 2 cost_float_mod_2 2 87.67 12.33 21 cost_float_mod_2 21 98.46 1.54 27 cost_float_mod_2 27 100.00 0.00 3 cost_float_mod_2 3 93.94 6.06 32 cost_float_mod_2 32 91.20 8.80 36 cost_float_mod_2 36 100.00 0.00 4 cost_float_mod_2 4 91.30 8.70 48 cost_float_mod_2 48 88.57 11.43 5 cost_float_mod_2 5 98.15 1.85 6 cost_float_mod_2 6 95.00 5.00 7 cost_float_mod_2 7 96.97 3.03 8 cost_float_mod_2 8 93.48 6.52 94 cost_float_mod_2 94 52.23 47.77 96 cost_float_mod_2 96 50.00 50.00 Otros_Colombia cost_float_mod_2 Otros_Colombia 100.00 0.00 Otros_España cost_float_mod_2 Otros_España 93.87 6.13 Otros_México cost_float_mod_2 Otros_México 76.19 23.81 Otros_Panamá cost_float_mod_2 Otros_Panamá 50.00 50.00 Otros_Perú cost_float_mod_2 Otros_Perú 80.95 19.05 cost_float_mod_3 100 cost_float_mod_3 100 50.00 50.00 1000005 cost_float_mod_3 1000005 54.55 45.45 1000010 cost_float_mod_3 1000010 66.67 33.33 1000015 cost_float_mod_3 1000015 66.67 33.33 1000016 cost_float_mod_3 1000016 100.00 0.00 1000017 cost_float_mod_3 1000017 83.33 16.67 1000018 cost_float_mod_3 1000018 83.33 16.67 1000019 cost_float_mod_3 1000019 50.00 50.00 1000020 cost_float_mod_3 1000020 72.22 27.78 1000021 cost_float_mod_3 1000021 66.67 33.33 1000022 cost_float_mod_3 1000022 74.60 25.40 1000023 cost_float_mod_3 1000023 86.36 13.64 1000030 cost_float_mod_3 1000030 77.78 22.22 1000031 cost_float_mod_3 1000031 66.67 33.33 1000038 cost_float_mod_3 1000038 37.50 62.50 1000039 cost_float_mod_3 1000039 39.13 60.87 1000054 cost_float_mod_3 1000054 60.00 40.00 1000058 cost_float_mod_3 1000058 83.33 16.67 1000096 cost_float_mod_3 1000096 84.62 15.38 1000118 cost_float_mod_3 1000118 42.86 57.14 103 cost_float_mod_3 103 82.61 17.39 104 cost_float_mod_3 104 66.67 33.33 105 cost_float_mod_3 105 83.33 16.67 106 cost_float_mod_3 106 66.67 33.33 107 cost_float_mod_3 107 33.33 66.67 108 cost_float_mod_3 108 66.67 33.33 109 cost_float_mod_3 109 83.33 16.67 11 cost_float_mod_3 11 93.75 6.25 112 cost_float_mod_3 112 88.89 11.11 113 cost_float_mod_3 113 100.00 0.00 115 cost_float_mod_3 115 83.33 16.67 117 cost_float_mod_3 117 72.41 27.59 12 cost_float_mod_3 12 81.48 18.52 15 cost_float_mod_3 15 97.56 2.44 17 cost_float_mod_3 17 85.07 14.93 18 cost_float_mod_3 18 97.67 2.33 2 cost_float_mod_3 2 90.75 9.25 21 cost_float_mod_3 21 95.38 4.62 27 cost_float_mod_3 27 100.00 0.00 3 cost_float_mod_3 3 93.94 6.06 32 cost_float_mod_3 32 94.40 5.60 36 cost_float_mod_3 36 100.00 0.00 4 cost_float_mod_3 4 95.65 4.35 48 cost_float_mod_3 48 88.57 11.43 5 cost_float_mod_3 5 96.30 3.70 6 cost_float_mod_3 6 96.67 3.33 7 cost_float_mod_3 7 100.00 0.00 8 cost_float_mod_3 8 97.10 2.90 94 cost_float_mod_3 94 53.50 46.50 96 cost_float_mod_3 96 50.00 50.00 Otros_Colombia cost_float_mod_3 Otros_Colombia 100.00 0.00 Otros_España cost_float_mod_3 Otros_España 92.33 7.67 Otros_México cost_float_mod_3 Otros_México 74.60 25.40 Otros_Panamá cost_float_mod_3 Otros_Panamá 50.00 50.00 Otros_Perú cost_float_mod_3 Otros_Perú 90.48 9.52 === ITE1 — Distribución por FM_RESPONSIBLE_MOD === clasificacion_estad_global_train_vs_test CAMBIO ESTABLE variable FM_RESPONSIBLE_MOD variable FM_RESPONSIBLE_MOD cost_float_mod Eficiencia Energética cost_float_mod Eficiencia Energética 79.92 20.08 Gestión Espacios cost_float_mod Gestión Espacios 100.00 0.00 Licencias cost_float_mod Licencias 98.31 1.69 Mantenimiento cost_float_mod Mantenimiento 83.90 16.10 Obras Proyectos cost_float_mod Obras Proyectos 91.30 8.70 cost_float_mod_2 Eficiencia Energética cost_float_mod_2 Eficiencia Energética 80.31 19.69 Gestión Espacios cost_float_mod_2 Gestión Espacios 100.00 0.00 Licencias cost_float_mod_2 Licencias 98.31 1.69 Mantenimiento cost_float_mod_2 Mantenimiento 83.29 16.71 Obras Proyectos cost_float_mod_2 Obras Proyectos 91.30 8.70 cost_float_mod_3 Eficiencia Energética cost_float_mod_3 Eficiencia Energética 79.34 20.66 Gestión Espacios cost_float_mod_3 Gestión Espacios 100.00 0.00 Licencias cost_float_mod_3 Licencias 98.31 1.69 Mantenimiento cost_float_mod_3 Mantenimiento 84.84 15.16 Obras Proyectos cost_float_mod_3 Obras Proyectos 91.30 8.70
Interpretación de los resultados para ITE1 e ITE2:¶
Por tipo de gasto (FM_COST_TYPE): Los más estables son Suministros y Eficiencia Energética, aunque en ITE2 también aparecen con cierta inestabilidad. En cambio, Licencias y Servicios Extra destacan por ser mucho más cambiantes, lo que las hace poco aptas para predicción automática.
Por uso del inmueble (TIPO_USO): Los más estables son Hoteles, Casinos y Oficinas, aunque con diferencias entre ITE1 e ITE2. En general, los locales de juego tradicional (bingos, salones, casinos) muestran más estabilidad que usos administrativos como delegaciones.
Por país (COUNTRY_DEF): España concentra los mayores porcentajes de cambio estructural, mientras que países más pequeños como Panamá o República Dominicana tienen un peso más equilibrado entre estable y cambio.
Por región (ID_REGION_GRUPO): Hay una gran variabilidad: algunas regiones muestran >90% de cambio, mientras que otras (pocas) alcanzan hasta 30–40% de estables.
Por responsable (FM_RESPONSIBLE_MOD): Eficiencia Energética y Obras Proyectos presentan un grupo más estable, mientras que Licencias y Gestión Espacios son claramente más cambiantes.
Conclusión: El patrón común es que la mayoría de categorías están dominadas por CAMBIO estructural, con solo unos pocos nichos donde encontramos estabilidad: Suministros, Eficiencia Energética, ciertos tipos de uso (ej. hoteles, casinos) y algunos responsables concretos (Obras Proyectos).
Guardamos los dataframes resultante de este bloque de la estrategia 2.¶
ITE1
df_fd1_v5_ITE1_train Panel mensual 2021–2023 agregado por pareja con contexto. Base de todo el análisis histórico.
df_fd1_v5_ITE1_test Panel mensual 2024 real por pareja con contexto. Base para validar.
df_train_enr (ITE1) Train enriquecido con outliers ya tratados: cost_float_mod_2, cost_float_mod_3, is_outlier, cost_float_mean_y. Lo usamos para modelizar variantes de coste y para el filtro de estabilidad.
df_estabilidad_rb_22_23 Resultados de estabilidad 2022–2023 vs 2024 (una fila por pareja y variable de coste) con métricas y banderas. Es la “verdad” de aptitud para predicción en ITE1.
resumenes ITE1 por contexto Tablas de distribución ESTABLE/CAMBIO por:
dist_tipo_gasto_ite1 (FM_COST_TYPE)
dist_uso_ite1 (TIPO_USO)
dist_pais_ite1 (COUNTRY_DEF)
dist_region_ite1 (ID_REGION_GRUPO)
dist_responsable_ite1 (FM_RESPONSIBLE_MOD) Nos sirven para “mapear” dónde hay más estabilidad.
ITE2
df_fd1_v5_ITE2_train Panel mensual 2022–2024 agregado por pareja con contexto.
df_fd1_v5_ITE2_test Panel mensual 2025 YTD (hasta agosto) por pareja con contexto.
df_ite2_train_enr Train ITE2 enriquecido con outliers tratados (mod_2, mod_3, etc.).
df_estabilidad_ite2 Resultados de estabilidad 2022–2024 vs 2025 YTD (una fila por pareja y variable de coste) con métricas y banderas.
resumenes ITE2 por contexto
dist_tipo_gasto_ite2, dist_uso_ite2, dist_pais_ite2, dist_region_ite2, dist_responsable_ite2.
Útiles para el modelado y reporting
pairs_both_ITE1 y pairs_both_ITE2 Listas de parejas comunes train–test. Nos aceleran muestreos y bucles.
listas de candidatas
candidatas_ITE1 = parejas con clasificación ESTABLE (o INCIERTA si luego añadimos la clase intermedia).
candidatas_ITE2 = idem para ITE2. Son los targets naturales para predicción con series temporales.
evaluaciones de modelos por serie (si ya las tenemos) Dataframe con métricas por pareja y variable de coste: MAE, WAPE, sMAPE, MASE, suma anual prevista vs real. Útil para auditoría.
# Ruta base 2 - para guardar los archivos resultado de la Estrategia 2.
ruta_base_2 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_2"
def _to_excel(df, filename, sheet_name="Sheet1"):
if df is not None and len(df) > 0:
df.to_excel(os.path.join(ruta_base_2, filename),
index=False, sheet_name=sheet_name, engine="openpyxl")
def _to_csv(df, filename):
if df is not None and len(df) > 0:
df.to_csv(os.path.join(ruta_base_2, filename),
index=False, sep=";")
# --- ITE1 ---
try: _to_excel(df_fd1_v5_ITE1_train, "df_fd1_v5_ITE1_train.xlsx"); _to_csv(df_fd1_v5_ITE1_train, "df_fd1_v5_ITE1_train.csv")
except: pass
try: _to_excel(df_fd1_v5_ITE1_test, "df_fd1_v5_ITE1_test.xlsx"); _to_csv(df_fd1_v5_ITE1_test, "df_fd1_v5_ITE1_test.csv")
except: pass
try: _to_excel(df_train_enr, "df_train_enr_ITE1.xlsx"); _to_csv(df_train_enr, "df_train_enr_ITE1.csv")
except: pass
try: _to_excel(df_estabilidad_rb_22_23, "df_estabilidad_rb_22_23_ITE1.xlsx"); _to_csv(df_estabilidad_rb_22_23, "df_estabilidad_rb_22_23_ITE1.csv")
except: pass
try: _to_excel(df_estabilidad, "df_estabilidad_ITE1.xlsx"); _to_csv(df_estabilidad, "df_estabilidad_ITE1.csv")
except: pass
try: _to_excel(df_estabilidad_rb, "df_estabilidad_rb_ITE1.xlsx"); _to_csv(df_estabilidad_rb, "df_estabilidad_rb_ITE1.csv")
except: pass
# --- ITE2 ---
try: _to_excel(df_fd1_v5_ITE2_train, "df_fd1_v5_ITE2_train.xlsx"); _to_csv(df_fd1_v5_ITE2_train, "df_fd1_v5_ITE2_train.csv")
except: pass
try: _to_excel(df_fd1_v5_ITE2_test, "df_fd1_v5_ITE2_test.xlsx"); _to_csv(df_fd1_v5_ITE2_test, "df_fd1_v5_ITE2_test.csv")
except: pass
try: _to_excel(df_ite2_train_enr, "df_ite2_train_enr.xlsx"); _to_csv(df_ite2_train_enr, "df_ite2_train_enr.csv")
except: pass
try: _to_excel(df_estabilidad_ite2, "df_estabilidad_ite2.xlsx"); _to_csv(df_estabilidad_ite2, "df_estabilidad_ite2.csv")
except: pass
# --- Resúmenes ITE1 ---
for name, df in {
"dist_tipo_gasto_ite1": globals().get("dist_tipo_gasto_ite1"),
"dist_uso_ite1": globals().get("dist_uso_ite1"),
"dist_pais_ite1": globals().get("dist_pais_ite1"),
"dist_region_ite1": globals().get("dist_region_ite1"),
"dist_responsable_ite1": globals().get("dist_responsable_ite1"),
}.items():
try:
_to_excel(df, f"{name}.xlsx")
_to_csv(df, f"{name}.csv")
except: pass
# --- Resúmenes ITE2 ---
for name, df in {
"dist_tipo_gasto_ite2": globals().get("dist_tipo_gasto_ite2"),
"dist_uso_ite2": globals().get("dist_uso_ite2"),
"dist_pais_ite2": globals().get("dist_pais_ite2"),
"dist_region_ite2": globals().get("dist_region_ite2"),
"dist_responsable_ite2": globals().get("dist_responsable_ite2"),
}.items():
try:
_to_excel(df, f"{name}.xlsx")
_to_csv(df, f"{name}.csv")
except: pass
print("Guardado completado en Excel y CSV (sep=';') en carpeta ESTRATEGIA_2.")
Guardado completado en Excel y CSV (sep=';') en carpeta ESTRATEGIA_2.
Guardamos en HTML¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_Analisis_serie_serie_estrategia_2.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 17 image(s). [NbConvertApp] Writing 3648609 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_Analisis_serie_serie_estrategia_2.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_Analisis_serie_serie_estrategia_2.html Existe: True
ESTRATEGIA 3¶
Conclusiones de las estrategias 1 y 2 obtenidos los resultados.¶
Por un lado hemos preseleccionado un modelo de predicción de gasto según unos kpis que deberían habernos llevado a un modelo adecuado para cada sub-dataframe segmentado. En cambio, el resultado ha sido que en la mayoría de series en las que hemos aplicado el modelo preseleccionado según kpis no era válido o daba métricas de validación muy malas. De esta primera estrategia, hemos visto que también era importante mirar como se va alternando el comportamiento estadístico de las series historicas con respecto las serie real que prever. El resultado de este analisis ha ido de muy malo con comportamientos estables en un 2-3% de las series (modalidad de clasificación estricta) a un 15-18% de estabilidad de las series usando una modalidad de clasificación más robusta (menos estricta).
Dada esta situación, creo que una posible opción sería segmentar el conjunto de entrenamiento solo con las series que son estables, dejando la mayoría fuera del proyecto. Otra opción sería aplicar a todas las series todos los modelos de predicción de series temporales que hemos probado y valorar confrontarlos entre si añadiendo el naive estacional y mensual para luego validar con métricas la mejor opción. O puedo hacer esta segunda opción combinando con la primera.
Propuesta para itinerario 3¶
Nos decantamos ir a por una opción pragmática y robusta que maximice acierto sin rehacer el proyecto: un “champion–challenger” automático con combinación de modelos (y baselines fuertes) para todas las series, con tratamientos específicos para intermitentes, y dejando visible un “subset estable” como garantía de calidad. Es, en la práctica, una mezcla de tus opciones 2 y 1, pero priorizando la 2. Seleccionar y combinar modelos por serie (no solo elegir uno)
Candidatos generales: SNaive(12), Naive(1), Theta, ETS(auto), ARIMA(auto), Holt.
Candidatos Intermitentes: Croston/SBA + TSB (probabilidad de ocurrencia), y ADIDA/MAPA por temporal aggregation (hacer trimestral, predecir y desagregar).
Validar con rolling origin corto (2–3 cortes en 2023) usando MASE12 como métrica primaria y WAPE secundaria.
Elegir top-2 por serie y combinarlos (media simple); si ninguno supera a SNaive(12), fallback = SNaive(12). Justificación: en M4, los combos superaron de forma sistemática a los modelos individuales; FFORMA (meta-aprendizaje) y otros confirman que la combinación ≫ selección aislada. No necesitamos implementar FFORMA para beneficiarnos de esto: una media de los mejores ya rinde muy bien. Rob J Hyndman+3ScienceDirect+3ResearchGate+3
Tratamiento específico de intermitentes
Probar TSB además de Croston/SBA (mejor manejo de obsolescencia/probabilidad de demanda).
Añadir ADIDA/MAPA: agregar en el tiempo (p. ej., a trimestral) reduce la intermitencia, se modela y luego se desagrega; funciona especialmente bien en intermitentes.
Regla de fallback: si el top-2 combinado no mejora SNaive(12), quedarse con SNaive(12) o con mediana estacional (últimos 2–3 años). Justificación: TSB suele superar a Croston/SBA en varios escenarios; y la temporal aggregation (ADIDA/MAPA) ha mostrado mejoras sustanciales en intermitentes. kourentzes.com+4ScienceDirect+4ScienceDirect+4
Paso 0¶
# =========================
# Bloque 1) Constantes y parámetros
# =========================
# Definimos rutas base de Estrategia 2 y Estrategia 3
ruta_base_2 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_2"
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
# Definimos subcarpetas de salida
SUBFOLDERS = ["RESULTADOS", "METRICAS", "LOGS", "FIGS"]
# Definimos parámetros canónicos de pares clave y fechas
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
# Definimos rangos de meses canónicos
TRAIN_START, TRAIN_END = "2021-01-01", "2023-12-01"
TEST_START, TEST_END = "2024-01-01", "2024-12-01"
# Nombres esperados de columnas de valor
VALUE_TRAINTEST = "cost_float_mod"
VALUE_ENR_MOD2 = "cost_float_mod2" # aceptaremos variantes con guion bajo
VALUE_ENR_MOD3 = "cost_float_mod3"
# Definimos separador estándar de CSV
CSV_SEP = ";"
# =========================
# Bloque 2) Funciones auxiliares
# =========================
def ensure_dirs(base_path: str, subfolders: list):
"""Creamos la estructura de carpetas de salida para Estrategia 3."""
os.makedirs(base_path, exist_ok=True)
for sf in subfolders:
os.makedirs(os.path.join(base_path, sf), exist_ok=True)
def log(msg: str):
"""Escribimos mensajes de trazabilidad en el log de preparación."""
print(msg)
with open(os.path.join(ruta_base_3, "LOGS", "estrategia3_setup.log"), "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
def coerce_fecha(df: pd.DataFrame) -> pd.DataFrame:
"""Aseguramos la columna FECHA. Si no existe, la construimos desde YEAR y MONTH."""
cols = set(df.columns.str.upper())
# Aceptamos distintas capitalizaciones
if "FECHA" in df.columns:
df["FECHA"] = pd.to_datetime(df["FECHA"]).dt.to_period("M").dt.to_timestamp()
return df
# Si no existe FECHA, intentamos con YEAR + MONTH
year_col = "YEAR" if "YEAR" in df.columns else None
month_col = "MONTH" if "MONTH" in df.columns else None
if year_col and month_col:
df["FECHA"] = pd.to_datetime(
dict(year=df[year_col].astype(int), month=df[month_col].astype(int), day=1)
).dt.to_period("M").dt.to_timestamp()
return df
raise ValueError("No encontramos FECHA ni YEAR+MONTH para construir la fecha.")
def canonical_month_range(start_str, end_str):
"""Generamos el rango mensual canónico."""
return pd.date_range(start=start_str, end=end_str, freq="MS")
def agg_remove_dups_and_reindex(df: pd.DataFrame, datecol: str, valuecol: str,
start: str, end: str,
pair_cols=PAIR_COLS, agg="sum") -> pd.DataFrame:
"""Agregamos duplicados por (pair, mes) y reindexamos mensualmente rellenando huecos a 0."""
# Agregamos por mes canónico
df_agg = (df.groupby(pair_cols + [pd.Grouper(key=datecol, freq="MS")], as_index=False)[valuecol]
.agg(agg))
# Reindexamos por pareja a malla mensual
all_pairs = df_agg[pair_cols].drop_duplicates()
months = canonical_month_range(start, end)
out = []
for bid, ctype in all_pairs.values:
sub = df_agg[(df_agg[pair_cols[0]]==bid) & (df_agg[pair_cols[1]]==ctype)].set_index(datecol)
sub = sub.reindex(months).rename_axis(datecol).reset_index()
sub[pair_cols[0]] = bid
sub[pair_cols[1]] = ctype
sub[valuecol] = sub[valuecol].fillna(0.0)
out.append(sub)
if not out:
# Devolvemos vacío con columnas esperadas si no hay pares
cols = pair_cols + [datecol, valuecol]
return pd.DataFrame(columns=cols)
df_full = pd.concat(out, ignore_index=True)
return df_full[[pair_cols[0], pair_cols[1], datecol, valuecol]]
def save_csv(df: pd.DataFrame, path: str):
"""Guardamos CSV con separador estándar del proyecto."""
df.to_csv(path, index=False, sep=CSV_SEP)
def normalize_enr_column_names(df: pd.DataFrame) -> pd.DataFrame:
"""Normalizamos nombres de columnas de outliers si vinieran como cost_float_mod_2/_3."""
rename_map = {}
if "cost_float_mod_2" in df.columns and VALUE_ENR_MOD2 not in df.columns:
rename_map["cost_float_mod_2"] = VALUE_ENR_MOD2
if "cost_float_mod_3" in df.columns and VALUE_ENR_MOD3 not in df.columns:
rename_map["cost_float_mod_3"] = VALUE_ENR_MOD3
if rename_map:
df = df.rename(columns=rename_map)
return df
# =========================
# Bloque 3) Ejecución
# =========================
# 3.1) Preparamos carpetas y log
ensure_dirs(ruta_base_3, SUBFOLDERS)
with open(os.path.join(ruta_base_3, "LOGS", "estrategia3_setup.log"), "w", encoding="utf-8") as f:
f.write("== LOG Preparación Estrategia 3 - Punto 0 ==\n")
log("Iniciamos el Punto 0 — Preparación.")
# 3.2) Cargamos ficheros de entrada
train_path = os.path.join(ruta_base_2, "df_fd1_v5_ITE1_train.xlsx")
test_path = os.path.join(ruta_base_2, "df_fd1_v5_ITE1_test.xlsx")
enr_path = os.path.join(ruta_base_2, "df_train_enr_ITE1.xlsx")
df_fd1_v5_ITE1_train = pd.read_excel(train_path, engine="openpyxl")
df_fd1_v5_ITE1_test = pd.read_excel(test_path, engine="openpyxl")
df_train_enr_ITE1 = pd.read_excel(enr_path, engine="openpyxl")
log("Ficheros cargados desde Estrategia 2.")
# 3.3) Aseguramos columna FECHA mensual canónica en los tres dataframes
df_fd1_v5_ITE1_train = coerce_fecha(df_fd1_v5_ITE1_train.copy())
df_fd1_v5_ITE1_test = coerce_fecha(df_fd1_v5_ITE1_test.copy())
df_train_enr_ITE1 = coerce_fecha(df_train_enr_ITE1.copy())
# 3.4) Validamos/normalizamos columnas de outliers en df_train_enr_ITE1
df_train_enr_ITE1 = normalize_enr_column_names(df_train_enr_ITE1)
# 3.5) Comprobamos presencia de columnas clave
missing_train = [c for c in [*PAIR_COLS, DATECOL, VALUE_TRAINTEST] if c not in df_fd1_v5_ITE1_train.columns]
missing_test = [c for c in [*PAIR_COLS, DATECOL, VALUE_TRAINTEST] if c not in df_fd1_v5_ITE1_test.columns]
missing_enr = [c for c in [*PAIR_COLS, DATECOL, VALUE_TRAINTEST, "is_outlier", VALUE_ENR_MOD2, VALUE_ENR_MOD3] if c not in df_train_enr_ITE1.columns]
if missing_train: log(f"AVISO: faltan columnas en TRAIN: {missing_train}")
if missing_test: log(f"AVISO: faltan columnas en TEST : {missing_test}")
if missing_enr: log(f"AVISO: faltan columnas en ENR : {missing_enr}")
# 3.6) Reindexamos mensualmente (agregando duplicados por suma y rellenando huecos a 0)
train_full = agg_remove_dups_and_reindex(df_fd1_v5_ITE1_train, DATECOL, VALUE_TRAINTEST,
start=TRAIN_START, end=TRAIN_END, pair_cols=PAIR_COLS, agg="sum")
test_full = agg_remove_dups_and_reindex(df_fd1_v5_ITE1_test, DATECOL, VALUE_TRAINTEST,
start=TEST_START, end=TEST_END, pair_cols=PAIR_COLS, agg="sum")
# 3.7) Construimos versión enriquecida para train (merge de mod2, mod3, is_outlier)
cols_enr_keep = [*PAIR_COLS, DATECOL, VALUE_TRAINTEST, "is_outlier", VALUE_ENR_MOD2, VALUE_ENR_MOD3]
cols_enr_keep = [c for c in cols_enr_keep if c in df_train_enr_ITE1.columns] # filtramos por si faltan
enr_dedup = df_train_enr_ITE1[cols_enr_keep].drop_duplicates()
# Nos quedamos con train_full y le añadimos las columnas enriquecidas por clave
train_full_enr = train_full.merge(enr_dedup, on=[*PAIR_COLS, DATECOL, VALUE_TRAINTEST], how="left")
# 3.8) Verificación de longitudes por pareja
verif = (train_full.groupby(PAIR_COLS)[DATECOL].nunique().rename("n_meses_train").reset_index()
.merge(test_full.groupby(PAIR_COLS)[DATECOL].nunique().rename("n_meses_test").reset_index(),
on=PAIR_COLS, how="outer").fillna(0))
verif["n_meses_train"] = verif["n_meses_train"].astype(int)
verif["n_meses_test"] = verif["n_meses_test"].astype(int)
verif["ok_train_36"] = (verif["n_meses_train"]==36)
verif["ok_test_12"] = (verif["n_meses_test"]==12)
save_csv(verif, os.path.join(ruta_base_3, "METRICAS", "verificacion_longitudes_train_test.csv"))
log(f"Parejas con train != 36: {(~verif['ok_train_36']).sum()} | con test != 12: {(~verif['ok_test_12']).sum()}")
# 3.9) Punto de control A: conteos y listas de pares
pairs_train = set(map(tuple, train_full[PAIR_COLS].drop_duplicates().values.tolist()))
pairs_test = set(map(tuple, test_full[PAIR_COLS].drop_duplicates().values.tolist()))
only_test = sorted(list(pairs_test - pairs_train))
only_train = sorted(list(pairs_train - pairs_test))
save_csv(pd.DataFrame(only_test, columns=PAIR_COLS), os.path.join(ruta_base_3, "METRICAS", "pairs_solo_test.csv"))
save_csv(pd.DataFrame(only_train, columns=PAIR_COLS), os.path.join(ruta_base_3, "METRICAS", "pairs_solo_train.csv"))
print("=== Punto de control A ===")
print("Parejas en train:", len(pairs_train))
print("Parejas en test :", len(pairs_test))
print("Parejas SOLO TEST (fallback SNaive12, sin histórico, cobertura=0):", len(only_test))
print("Parejas SOLO TRAIN:", len(only_train))
# 3.10) Marcado de SOLO TEST como sin histórico para trazabilidad de modelo_final
if only_test:
df_only_test = pd.DataFrame(only_test, columns=PAIR_COLS)
df_only_test["modelo_final"] = "fallback_snaive12"
df_only_test["cobertura"] = 0
save_csv(df_only_test, os.path.join(ruta_base_3, "METRICAS", "solo_test_modelo_final.csv"))
# 3.11) Guardamos salidas canónicas
save_csv(train_full, os.path.join(ruta_base_3, "RESULTADOS", "train_full_2021_2023.csv"))
save_csv(train_full_enr, os.path.join(ruta_base_3, "RESULTADOS", "train_full_enr_2021_2023.csv"))
save_csv(test_full, os.path.join(ruta_base_3, "RESULTADOS", "test_full_2024.csv"))
log("Guardados canónicos generados.")
# 3.12) Vista preliminar (diagnóstico visual mínimo)
print("\n== df_fd1_v5_ITE1_train.head() ==")
display(df_fd1_v5_ITE1_train.head(5))
print("\n== df_fd1_v5_ITE1_test.head() ==")
display(df_fd1_v5_ITE1_test.head(5))
print("\n== df_train_enr_ITE1.head() ==")
display(df_train_enr_ITE1.head(5))
# Confirmamos columnas de outliers presentes en el enriquecido
present_outlier_cols = [c for c in [VALUE_ENR_MOD2, VALUE_ENR_MOD3] if c in df_train_enr_ITE1.columns]
print("\nColumnas de outliers presentes en df_train_enr_ITE1:", present_outlier_cols)
log("Punto 0 finalizado.")
Iniciamos el Punto 0 — Preparación. Ficheros cargados desde Estrategia 2. Parejas con train != 36: 461 | con test != 12: 198 === Punto de control A === Parejas en train: 2429 Parejas en test : 2692 Parejas SOLO TEST (fallback SNaive12, sin histórico, cobertura=0): 461 Parejas SOLO TRAIN: 198 Guardados canónicos generados. == df_fd1_v5_ITE1_train.head() ==
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Eficiencia Energética | 2021-12-01 | 2021 | 12 | 0.00 | España | 2 | Oficinas | INTERNO | Eficiencia Energética |
1 | 2 | Licencias | 2021-01-01 | 2021 | 1 | 1145.46 | España | 2 | Oficinas | INTERNO | Licencias |
2 | 2 | Licencias | 2021-02-01 | 2021 | 2 | 55.95 | España | 2 | Oficinas | INTERNO | Licencias |
3 | 2 | Licencias | 2021-03-01 | 2021 | 3 | 0.00 | España | 2 | Oficinas | INTERNO | Licencias |
4 | 2 | Licencias | 2021-04-01 | 2021 | 4 | 0.00 | España | 2 | Oficinas | INTERNO | Licencias |
== df_fd1_v5_ITE1_test.head() ==
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | MONTH | cost_float_mod | COUNTRY_DEF | ID_REGION_GRUPO | TIPO_USO | SUPPLIER_TYPE_MOD_2 | FM_RESPONSIBLE_MOD | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 2 | Licencias | 2024-12-01 | 2024 | 12 | 202.80 | España | 2 | Oficinas | INTERNO | Licencias |
1 | 2 | Mtto. Contratos | 2024-01-01 | 2024 | 1 | 1874.00 | España | 2 | Oficinas | EXTERNO | Mantenimiento |
2 | 2 | Mtto. Contratos | 2024-02-01 | 2024 | 2 | 0.00 | España | 2 | Oficinas | EXTERNO | Mantenimiento |
3 | 2 | Mtto. Contratos | 2024-03-01 | 2024 | 3 | 0.00 | España | 2 | Oficinas | EXTERNO | Mantenimiento |
4 | 2 | Mtto. Contratos | 2024-04-01 | 2024 | 4 | 175.58 | España | 2 | Oficinas | EXTERNO | Mantenimiento |
== df_train_enr_ITE1.head() ==
ID_BUILDING | FM_COST_TYPE | FECHA | YEAR | cost_float_mod | is_outlier | cost_float_mod2 | cost_float_mean_y | cost_float_mod3 | |
---|---|---|---|---|---|---|---|---|---|
0 | 2 | Eficiencia Energética | 2021-12-01 | 2021 | 0.00 | 0 | 0.000000 | NaN | 0.0000 |
1 | 2 | Licencias | 2021-01-01 | 2021 | 1145.46 | 1 | 255.576875 | 219.4425 | 219.4425 |
2 | 2 | Licencias | 2021-02-01 | 2021 | 55.95 | 0 | 241.589375 | 219.4425 | 55.9500 |
3 | 2 | Licencias | 2021-03-01 | 2021 | 0.00 | 0 | 185.639375 | 219.4425 | 0.0000 |
4 | 2 | Licencias | 2021-04-01 | 2021 | 0.00 | 0 | 185.639375 | 219.4425 | 0.0000 |
Columnas de outliers presentes en df_train_enr_ITE1: ['cost_float_mod2', 'cost_float_mod3'] Punto 0 finalizado.
Interpretación paso 0¶
Hacemos la lectura y dejamos las acciones inmediatas.
Lectura de resultados del Punto 0
Parejas en train: 2.429
Parejas en test: 2.692
Solo test: 461
Solo train: 198
Verificación de longitudes
train != 36: 461
test != 12: 198
Interpretación:
Estos desajustes son coherentes con los conjuntos solo test y solo train. Al reindexar, únicamente reindexamos las parejas presentes en cada dataset; por tanto:
Las solo test no aparecen en train_full y por eso su n_meses_train resulta 0 (de ahí los 461 casos train != 36).
Las solo train no aparecen en test_full y por eso su n_meses_test resulta 0 (de ahí los 198 casos test != 12).
Conclusión: el conteo cuadra y confirma que el relleno 36/12 está bien aplicado para las parejas que sí existen en cada partición.
Muestras visuales
df_fd1_v5_ITE1_train.head() y df_fd1_v5_ITE1_test.head() muestran estructura y columnas esperadas, con FECHA mensual y cost_float_mod.
df_train_enr_ITE1.head() confirma la presencia de cost_float_mod2 y cost_float_mod3, además de is_outlier y cost_float_mean_y.
Mensaje adicional: “Columnas de outliers presentes…” confirma ['cost_float_mod2', 'cost_float_mod3']. Correcto.
Qué dejamos hecho en salidas canónicas
RESULTADOS/train_full_2021_2023.csv
RESULTADOS/train_full_enr_2021_2023.csv
RESULTADOS/test_full_2024.csv
METRICAS/verificacion_longitudes_train_test.csv
METRICAS/pairs_solo_test.csv
METRICAS/pairs_solo_train.csv
METRICAS/solo_test_modelo_final.csv con modelo_final="fallback_snaive12" y cobertura=0 para trazabilidad de las solo test.
LOGS/estrategia3_setup.log actualizado.
Decisiones y acciones inmediatas
Solo test (461): las marcamos como sin histórico, modelo_final="fallback_snaive12", cobertura=0. No entrarán en modelización ni en métricas de validación de 2024 a nivel serie.
Solo train (198): las tratamos como sin cobertura en test. No se evaluarán en 2024; podrán considerarse para futuros horizontes si hay datos posteriores.
Contexto para enriquecido: en Punto 1 dejaremos preparado el merge opcional de contexto (COUNTRY_DEF, ID_REGION_GRUPO, TIPO_USO) sobre train_full_enr_2021_2023.csv por ["ID_BUILDING","FM_COST_TYPE","FECHA"], tras hacer drop_duplicates en la base de contexto.
Paso 1 - Creación de Baselines¶
Proponemos este enfoque:
Enfoque por “baselines” antes que modelos
Partimos de referencias simples (Naive(1), SNaive(12), mediana estacional) para saber “qué tan difícil” es cada serie y disponer de un suelo de rendimiento. Así cualquier modelo posterior (ETS/ARIMA/ML) debe superar estas líneas base; si no, sabemos que no aporta valor.
Métricas robustas y comparables
MAE: error absoluto medio, fácil de interpretar en unidades de coste.
WAPE: escala por el volumen real; útil para comparaciones entre series con magnitudes distintas.
SMAPE: maneja bien ceros y valores pequeños gracias al denominador simétrico; evitamos explosiones con eps.
MASE1 / MASE12: convierten el error en “veces el Naive(1) / SNaive(12)”. Interpretación directa: ≈1 significa “igual que el baseline”; < 1, mejor; > 1, peor. Son clave para comparar entre series heterogéneas.
“Denominadores seguros” (eps, sum(|y|)+eps): previenen NaN/Inf en series con ceros/muy bajos y nos evitan falsos diagnósticos de rendimiento.
Escalas de MASE calculadas solo con train
Evitamos fuga de información: la normalización del error (las escalas) se calcula en 2021-2023 y se aplica a 2024. Así, la métrica refleja capacidad de generalizar y no “aprende” del test.
Definición precisa de cada baseline
Naive(1) “plano” en horizonte: usamos el último valor observado de train para todo 2024, sin actualizar con la verdad de test. Esto garantiza comparabilidad entre series y evita introducir dependencia del futuro.
SNaive(12): refleja estacionalidad anual mensual→mensual (nuestro caso); es el comparador natural en datos mensuales con patrón anual.
Mediana estacional por mes: más robusta que la media ante outliers en costes y funciona bien en series intermitentes/ruidosas.
Funciones utilitarias numéricas y función de evaluación única
_safe_div, _wape_safe, _smape_safe: consolidan manejo de casos límite y mantienen el código limpio.
_eval_series(...): centraliza el cálculo de métricas para cualquier baseline/modelo futuro. Ganamos reutilización y reducimos errores al extender la estrategia.
Alineación temporal y control de calidad
Ordenar por FECHA y asegurar alineación exacta y_true-y_pred evita desajustes de un mes que distorsionan métricas.
“Punto de control B (MASE12 ≈ 1)”: es un "sanity check" potente. Si SNaive(12) no da ≈1 en series con estacionalidad clara, probablemente hay:
desfase de meses,
escalas mal calculadas (falta de 12 lags),
o datos anómalos.
Detectarlo pronto ahorra horas aguas abajo.
Persistencia incremental en CSV con cabecera
Crear metrics_baselines_cv.csv vacío con cabecera nos permite:
ejecutar por tandas, reintentar sin romper el pipeline,
y appendeo controlado (sin duplicar cabecera). Esto encaja con nuestra forma de trabajo iterativa y con el versionado en Drive.
Estructura del código y estilo de comentarios
Separar constantes/parámetros, funciones y “resto del código” facilita leer, depurar y reusar.
Bloques individuales por acción -> control granular para ejecutar/rehacer partes sin rehacer todo.
Con el CSV obtenido, podremos:
comparar rápidamente modelos nuevos vs baselines por pareja,
hacer agregados por país/uso/portfolio,
y seleccionar automáticamente “victorias” respecto a SNaive(12) (MASE12 < 1) como criterio mínimo de aceptación.
Bloque 1 - Parámetros y rutas del Paso 1¶
# ===========================
# Paso 1 — Parámetros y rutas
# ===========================
# Asumimos que ya hemos ejecutado el Paso 0 y existen:
# - ruta_base_3, SUBFOLDERS, CSV_SEP
# - PAIR_COLS, DATECOL, VALUE_TRAINTEST
# - train_full (2021–2023) y test_full (2024)
# - funciones ensure_dirs() y log()
# Aseguramos carpetas por si se invoca el Paso 1 de forma aislada
ensure_dirs(ruta_base_3, SUBFOLDERS)
ruta_metricas = os.path.join(ruta_base_3, "METRICAS")
out_csv = os.path.join(ruta_metricas, "metrics_baselines_cv.csv")
cols_csv = ["ID_BUILDING","FM_COST_TYPE","baseline","n_obs","MAE","WAPE","SMAPE","MASE1","MASE12"]
# Creamos CSV con cabecera si no existe
if not os.path.exists(out_csv):
pd.DataFrame(columns=cols_csv).to_csv(out_csv, sep=CSV_SEP, index=False)
log(f"Inicializamos {out_csv} con cabecera.")
# Verificamos disponibilidad de dataframes canónicos del Paso 0
assert "train_full" in globals(), "Falta train_full (generado en el Paso 0)."
assert "test_full" in globals(), "Falta test_full (generado en el Paso 0)."
# Ordenamos y tipamos por seguridad
train_df = train_full.sort_values([*PAIR_COLS, DATECOL]).reset_index(drop=True).copy()
test_df = test_full.sort_values([*PAIR_COLS, DATECOL]).reset_index(drop=True).copy()
Bloque 2 - Funciones auxiliares y evaluación (robustas)¶
# ==========================================
# Paso 1 — Funciones auxiliares y métricas
# ==========================================
# Implementamos utilitarias robustas siguiendo el estándar del proyecto.
def _safe_div(num, den, eps=1e-8):
# Implementamos división segura para evitar divisiones por cero.
return num / (den + eps)
def _mae(y_true, y_pred):
# Calculamos el MAE de forma directa.
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return np.mean(np.abs(y_pred - y_true))
def _wape_safe(y_true, y_pred, eps=1e-8):
# Calculamos el WAPE con denominador seguro sum(|y_true|)+eps.
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.sum(np.abs(y_pred - y_true))
den = np.sum(np.abs(y_true))
return _safe_div(num, den, eps)
def _smape_safe(y_true, y_pred, eps=1e-8):
# Calculamos el SMAPE con denominador simétrico y eps para estabilidad.
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = (np.abs(y_true) + np.abs(y_pred)) / 2.0
return np.mean(_safe_div(num, den, eps)) * 100.0
def _scale_diff(series, lag):
# Calculamos la escala como media de |y_t - y_{t-lag}| sobre train.
arr = np.asarray(series, dtype=float)
if len(arr) <= lag:
return np.nan
diffs = np.abs(arr[lag:] - arr[:-lag])
if len(diffs) == 0:
return np.nan
return np.mean(diffs)
def mase_from_scale(errors, scale):
# Calculamos el MASE a partir de un vector de errores y una escala.
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
errors = np.asarray(errors, dtype=float)
return np.mean(np.abs(errors)) / float(scale)
def _eval_series(y_true, y_pred, escalas_train: dict):
# Calculamos las métricas comunes con las escalas proporcionadas del train.
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
err = y_pred - y_true
mae = _mae(y_true, y_pred)
wape = _wape_safe(y_true, y_pred)
smape = _smape_safe(y_true, y_pred)
mase1 = mase_from_scale(err, escalas_train.get("scale1"))
mase12 = mase_from_scale(err, escalas_train.get("scale12"))
return {"MAE": mae, "WAPE": wape, "SMAPE": smape, "MASE1": mase1, "MASE12": mase12}
Bloque 3 - Baselines por pareja y persistencia¶
# ==========================================
# Paso 1 — Baselines por pareja y guardado
# ==========================================
# Generamos Naive(1), SNaive(12) y Mediana estacional, evaluamos y persistimos.
# Construimos sets para saber qué pares tienen test y train
pairs_train = set(map(tuple, train_df[PAIR_COLS].drop_duplicates().values.tolist()))
pairs_test = set(map(tuple, test_df[PAIR_COLS].drop_duplicates().values.tolist()))
pairs_commons = sorted(list(pairs_train & pairs_test))
pairs_only_test = sorted(list(pairs_test - pairs_train))
if pairs_only_test:
log(f"Advertencia: {len(pairs_only_test)} parejas SOLO TEST, se omiten en baselines por falta de histórico.")
rows_out = []
for bid, ctype in pairs_commons:
# Filtramos serie train y test
tr = train_df[(train_df[PAIR_COLS[0]]==bid) & (train_df[PAIR_COLS[1]]==ctype)].copy()
te = test_df [(test_df [PAIR_COLS[0]]==bid) & (test_df [PAIR_COLS[1]]==ctype)].copy()
if te.empty or tr.empty:
continue # nos aseguramos de tener ambos lados
# Alineamos por fecha y extraemos vectores
tr = tr.sort_values(DATECOL)
te = te.sort_values(DATECOL)
y_train = tr[VALUE_TRAINTEST].astype(float).values
y_true = te[VALUE_TRAINTEST].astype(float).values
n_obs = len(y_true)
# Calculamos escalas MASE sobre train
scale1 = _scale_diff(y_train, lag=1)
scale12 = _scale_diff(y_train, lag=12)
escalas = {"scale1": scale1, "scale12": scale12}
# Baseline Naive(1): último valor del train replicado
last_train = y_train[-1]
y_pred_naive1 = np.repeat(last_train, n_obs)
m = _eval_series(y_true, y_pred_naive1, escalas)
rows_out.append({
"ID_BUILDING": bid, "FM_COST_TYPE": ctype,
"baseline": "Naive1", "n_obs": n_obs, **m
})
# Baseline SNaive(12): mismo mes del año anterior; anclamos 2024 contra 2023
tr["MONTH_NUM"] = pd.to_datetime(tr[DATECOL]).dt.month
te["MONTH_NUM"] = pd.to_datetime(te[DATECOL]).dt.month
tr_2023 = tr[pd.to_datetime(tr[DATECOL]).dt.year == 2023].copy()
y_pred_snaive12 = np.full(n_obs, np.nan, dtype=float)
if not tr_2023.empty:
map_2023 = tr_2023.set_index("MONTH_NUM")[VALUE_TRAINTEST].to_dict()
for i, mth in enumerate(te["MONTH_NUM"].values):
if mth in map_2023:
y_pred_snaive12[i] = map_2023[mth]
# Fallback: mediana mensual de 2021–2023 si faltan meses de 2023
if np.isnan(y_pred_snaive12).any():
mediana_mensual = tr.groupby("MONTH_NUM")[VALUE_TRAINTEST].median().to_dict()
for i, mth in enumerate(te["MONTH_NUM"].values):
if np.isnan(y_pred_snaive12[i]) and mth in mediana_mensual:
y_pred_snaive12[i] = mediana_mensual[mth]
# Último fallback: último valor de train si aún quedan NaN
if np.isnan(y_pred_snaive12).any():
y_pred_snaive12 = np.where(np.isnan(y_pred_snaive12), last_train, y_pred_snaive12)
m = _eval_series(y_true, y_pred_snaive12, escalas)
rows_out.append({
"ID_BUILDING": bid, "FM_COST_TYPE": ctype,
"baseline": "SNaive12", "n_obs": n_obs, **m
})
# Baseline Mediana estacional por mes (robusto a outliers)
mediana_mensual = tr.groupby("MONTH_NUM")[VALUE_TRAINTEST].median()
y_pred_med = te["MONTH_NUM"].map(mediana_mensual).astype(float).values
if np.isnan(y_pred_med).any():
y_pred_med = np.where(np.isnan(y_pred_med), last_train, y_pred_med)
m = _eval_series(y_true, y_pred_med, escalas)
rows_out.append({
"ID_BUILDING": bid, "FM_COST_TYPE": ctype,
"baseline": "MedianaEstacional", "n_obs": n_obs, **m
})
# Persistimos resultados en modo append
if rows_out:
df_out = pd.DataFrame(rows_out)[cols_csv]
df_out.to_csv(out_csv, sep=CSV_SEP, index=False, mode="a", header=False)
log(f"Guardadas {len(rows_out)} filas de métricas de baselines en {out_csv}.")
else:
log("No se generaron filas de métricas (revisar disponibilidad de pares comunes).")
Advertencia: 461 parejas SOLO TEST, se omiten en baselines por falta de histórico. Guardadas 6693 filas de métricas de baselines en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_cv.csv.
Bloque 4 - Punto de control B — MASE12 ≈ 1 para SNaive(12)¶
# ==========================================
# Paso 1 — Punto de control B: MASE12 ≈ 1
# ==========================================
# Validamos que SNaive(12) tenga MASE12 cercano a 1 en series con suficiente histórico.
# Construimos resumen de disponibilidad
hist = (train_df.groupby(PAIR_COLS)
.agg(n_train=(DATECOL, "nunique"))
.reset_index())
has_test = (test_df.groupby(PAIR_COLS)
.agg(n_test=(DATECOL, "nunique"))
.reset_index())
panel = hist.merge(has_test, on=PAIR_COLS, how="inner")
candidatos = panel[(panel["n_train"] >= 24) & (panel["n_test"] >= 12)]
# Cargamos métricas y filtramos SNaive12 sobre las parejas candidatas
check_df = pd.read_csv(out_csv, sep=CSV_SEP)
check_df = check_df[check_df["baseline"] == "SNaive12"]
if not candidatos.empty and not check_df.empty:
mask = pd.merge(check_df[PAIR_COLS].drop_duplicates(), candidatos[PAIR_COLS], on=PAIR_COLS, how="inner")
check_pairs = pd.merge(check_df, mask, on=PAIR_COLS, how="inner")
else:
check_pairs = pd.DataFrame(columns=cols_csv)
fails = []
for _, r in check_pairs.iterrows():
m12 = r["MASE12"]
if not pd.isna(m12):
try:
assert abs(m12 - 1.0) < 0.10
except AssertionError:
fails.append((r["ID_BUILDING"], r["FM_COST_TYPE"], m12))
print(f"Series verificadas (SNaive12 con MASE12≈1): {len(check_pairs) - len(fails)} / {len(check_pairs)}")
if fails:
print("Parejas fuera de tolerancia |MASE12-1|<0.10 (mostramos hasta 10):")
for t in fails[:10]:
print(" -", t)
# Si el número de fallos es alto, revisaremos que:
# 1) La alineación 2024-mes vs 2023-mes sea correcta.
# 2) La escala12 se haya calculado con suficientes lags (>= 13 puntos en train).
# 3) No estemos ante series sin estacionalidad clara o con outliers que alteren el patrón anual.
Series verificadas (SNaive12 con MASE12≈1): 242 / 2231 Parejas fuera de tolerancia |MASE12-1|<0.10 (mostramos hasta 10): - (2, 'Licencias', 0.0675809646949442) - (2, 'Mtto. Contratos', 2.0247738210077566) - (2, 'Mtto. Correctivo', 0.54078965972694) - (2, 'Obras', 0.0095601541180889) - (2, 'Servicios Ctto.', 13.675578361454166) - (2, 'Servicios Extra', 0.6451420029895367) - (2, 'Suministros', 0.6910076996515223) - (9, 'Licencias', 1.7526171050032666) - (9, 'Mtto. Contratos', 0.8979701546545737) - (9, 'Mtto. Correctivo', 1.4839386748570396)
Bloque 5 - Resumen agregado por portfolio¶
Leemos metrics_baselines_cv.csv
y generamos resúmenes útiles a nivel global y por baseline, además de identificar el baseline “ganador” por pareja. Mantenemos rutas, CSV_SEP, PAIR_COLS y trazabilidad con log().
# ==========================================
# Paso 1 — Bloque 5) Resumen agregado por portfolio
# ==========================================
# En este bloque realizamos resúmenes globales y por baseline, e identificamos el
# baseline "ganador" por pareja usando como métrica principal MASE12 (más próxima a 0 mejor).
# Guardamos varios CSVs de salida en METRICAS para análisis y reporting.
ruta_metricas = os.path.join(ruta_base_3, "METRICAS")
in_csv = os.path.join(ruta_metricas, "metrics_baselines_cv.csv")
assert os.path.exists(in_csv), f"No existe {in_csv}. Debemos ejecutar antes los bloques de baselines."
dfm = pd.read_csv(in_csv, sep=CSV_SEP)
# Nos aseguramos de tipos numéricos
metric_cols = ["MAE","WAPE","SMAPE","MASE1","MASE12"]
for c in metric_cols + ["n_obs"]:
dfm[c] = pd.to_numeric(dfm[c], errors="coerce")
# 5.1) Resumen global por baseline (media y mediana)
summary_by_baseline = (
dfm.groupby("baseline")
.agg(
n_pairs=("ID_BUILDING","count"),
MAE_mean=("MAE","mean"), MAE_median=("MAE","median"),
WAPE_mean=("WAPE","mean"), WAPE_median=("WAPE","median"),
SMAPE_mean=("SMAPE","mean"), SMAPE_median=("SMAPE","median"),
MASE1_mean=("MASE1","mean"), MASE1_median=("MASE1","median"),
MASE12_mean=("MASE12","mean"), MASE12_median=("MASE12","median")
)
.reset_index()
.sort_values("MASE12_mean")
)
# 5.2) Baseline “ganador” por pareja según MASE12 (mínimo)
# Empatamos usando orden de preferencia: SNaive12 > MedianaEstacional > Naive1
prefer = {"SNaive12": 0, "MedianaEstacional": 1, "Naive1": 2}
dfm["_pref"] = dfm["baseline"].map(prefer).fillna(9)
df_sorted = dfm.sort_values(["ID_BUILDING","FM_COST_TYPE","MASE12","_pref"], ascending=[True,True,True,True])
best_by_pair = df_sorted.groupby(PAIR_COLS, as_index=False).first()[
[*PAIR_COLS, "baseline", "MASE12", "MAE", "WAPE", "SMAPE", "MASE1"]
].rename(columns={"baseline":"baseline_winner", "MASE12":"MASE12_winner"})
# 5.3) Cuota de victorias por baseline
rankshare = (
best_by_pair["baseline_winner"]
.value_counts(dropna=False)
.rename_axis("baseline_winner")
.reset_index(name="wins")
.assign(share=lambda d: d["wins"] / d["wins"].sum())
.sort_values("wins", ascending=False)
)
# 5.4) Resumen global (todas las filas)
overall_summary = (
dfm.agg({
"MAE":"mean", "WAPE":"mean", "SMAPE":"mean", "MASE1":"mean", "MASE12":"mean"
}).to_frame(name="mean").T
)
overall_summary["n_rows"] = len(dfm)
overall_summary["n_pairs"] = dfm.groupby(PAIR_COLS).ngroups
# 5.5) Guardamos salidas
p1 = os.path.join(ruta_metricas, "metrics_baselines_summary_by_baseline.csv")
p2 = os.path.join(ruta_metricas, "metrics_baselines_best_by_pair.csv")
p3 = os.path.join(ruta_metricas, "metrics_baselines_rankshare.csv")
p4 = os.path.join(ruta_metricas, "metrics_baselines_overall_summary.csv")
summary_by_baseline.to_csv(p1, sep=CSV_SEP, index=False)
best_by_pair.to_csv(p2, sep=CSV_SEP, index=False)
rankshare.to_csv(p3, sep=CSV_SEP, index=False)
overall_summary.to_csv(p4, sep=CSV_SEP, index=False)
log(f"Resumen por baseline guardado en: {p1}")
log(f"Ganador por pareja guardado en: {p2}")
log(f"Cuota de victorias por baseline guardado en: {p3}")
log(f"Resumen global guardado en: {p4}")
# 5.6) Impresión rápida para revisión
print("\n== Resumen global (medias) ==")
display(overall_summary)
print("\n== Resumen por baseline (ordenado por MASE12_mean ascendente) ==")
display(summary_by_baseline)
print("\n== Cuota de victorias por baseline (conteo y proporción) ==")
display(rankshare.head(10))
# - Revisaremos qué baseline presenta menor MASE12_mean en el resumen por baseline.
# - Observaremos en rankshare qué baseline gana más parejas (baseline_winner).
# - Si SNaive12 domina en victorias y en MASE12_mean, entenderemos que existe estacionalidad anual fuerte.
# - Si MedianaEstacional gana en series intermitentes o ruidosas, lo tendremos en cuenta para los fallbacks.
# - Si Naive1 ganara en muchos casos, revisaremos tendencia/estacionalidad ausente o problemas de datos.
Resumen por baseline guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_summary_by_baseline.csv Ganador por pareja guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_best_by_pair.csv Cuota de victorias por baseline guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_rankshare.csv Resumen global guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_overall_summary.csv == Resumen global (medias) ==
MAE | WAPE | SMAPE | MASE1 | MASE12 | n_rows | n_pairs | |
---|---|---|---|---|---|---|---|
mean | 606.712896 | 1.559903e+09 | 64.585492 | 3.469964 | 4.523489 | 6693 | 2231 |
== Resumen por baseline (ordenado por MASE12_mean ascendente) ==
baseline | n_pairs | MAE_mean | MAE_median | WAPE_mean | WAPE_median | SMAPE_mean | SMAPE_median | MASE1_mean | MASE1_median | MASE12_mean | MASE12_median | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | MedianaEstacional | 2231 | 663.668062 | 172.509848 | 1.908440e+08 | 0.865073 | 68.061196 | 56.882021 | 3.814405 | 1.111074 | 4.510965 | 1.068718 |
1 | Naive1 | 2231 | 566.861543 | 159.841667 | 2.677334e+09 | 0.931867 | 63.702404 | 50.000000 | 3.289550 | 1.077607 | 4.523964 | 0.982132 |
2 | SNaive12 | 2231 | 589.609083 | 171.250000 | 1.811532e+09 | 0.835532 | 61.992876 | 51.275403 | 3.305935 | 1.180648 | 4.535538 | 1.095457 |
== Cuota de victorias por baseline (conteo y proporción) ==
baseline_winner | wins | share | |
---|---|---|---|
0 | Naive1 | 860 | 0.385477 |
1 | SNaive12 | 797 | 0.357239 |
2 | MedianaEstacional | 574 | 0.257284 |
Interpretación Paso 1:¶
Bloque 3 (Baselines generados)
Se han omitido 461 parejas SOLO TEST porque no tienen histórico de 2021-2023.
Hemos guardado 6693 filas de métricas, que corresponden exactamente a 2231 parejas × 3 baselines (Naive1, SNaive12, MedianaEstacional).
Esto confirma que el pipeline funciona bien y que tenemos cobertura completa en las parejas comunes train+test.
Bloque 4 (Punto de control B - MASE12≈1 en SNaive12)
Verificamos 2231 parejas con baseline SNaive12.
Solo 242/2231 ≈ 11% cumplen que MASE12≈1 dentro de la tolerancia [0.90, 1.10].
El resto se desvía, en algunos casos con valores extremos (ejemplo: Servicios Ctto. con MASE12 ≈ 13.67). Esto nos indica que:
Muchas series no presentan estacionalidad anual estable entre 2021-2023 y 2024.
En otros casos, el cálculo de escala12 se ve afectado por series con muy pocos datos útiles o con valores atípicos.
El punto de control cumple su función: ha revelado que no podemos asumir que SNaive12 sea siempre un comparador perfecto.
Bloque 5 (Resumen agregado)
Resumen global
MAE medio ≈ 607.
WAPE medio absurdamente alto (≈ 1.56e+09), lo que sugiere que en muchas series los valores reales son casi cero -> el denominador de WAPE explota.
SMAPE medio ≈ 64.6%, lo cual refleja errores relativos altos.
MASE1 medio ≈ 3.47 y MASE12 medio ≈ 4.52, ambos >> 1 -> ningún baseline es competitivo frente a la escala de error natural.
Los baselines nos muestran un piso bajo de calidad, y nos dejan claro que necesitamos modelos más sofisticados para mejorar.
Resumen por baseline
MedianaEstacional: MASE12_mean ≈ 4.51 (ligeramente mejor).
Naive1: MASE12_mean ≈ 4.52.
SNaive12: MASE12_mean ≈ 4.54.
Las diferencias son muy pequeñas. En media, no hay un baseline claramente dominante.
Cuota de victorias por pareja (según MASE12 mínimo)
Naive1 gana en 860 parejas (38.5%).
SNaive12 gana en 797 parejas (35.7%).
MedianaEstacional gana en 574 parejas (25.7%).
La competencia entre Naive1 y SNaive12 está muy ajustada, pero Naive1 obtiene más victorias globales. Esto confirma que la mayoría de series no tienen estacionalidad clara y se comportan más bien planas/tendenciales a corto plazo.
Conclusión para el proyecto OPEX FM:
El diagnóstico de baselines nos da una primera referencia importante:
La mayoría de series no se comportan de forma estacional pura (SNaive12 no domina).
Los errores relativos son altos, así que cualquier modelo que logre reducir un 30-40% frente a estos baselines ya supondrá una mejora significativa.
La selección de baseline “ganador” por pareja nos servirá después como criterio de fallback: si un modelo avanzado no supera al baseline que ganó esa serie, no merece la pena implementarlo para esa pareja.
Paso 2 - Segmentación por intermitencia¶
En el Paso 2 lo que nos proponemos, en esencia, es convertir un conjunto heterogéneo de series en dos rutas de trabajo claras (INTERMITENTE vs GENERAL) usando KPIs simples y trazables. Esto nos da foco, velocidad y control antes de entrar en modelado “pesado”. En detalle, logramos:
Decidir la “ruta” adecuada para cada serie
INTERMITENTE: series con muchos ceros o rachas largas de ceros (prop_zeros ≥ 0.35 o max_racha_0 ≥ 4). Son candidatas a Croston/SBA/IMAPA y variantes para demanda intermitente.
GENERAL: el resto; propias de pipelines ETS/ARIMA/Holt + reglas y fallbacks ya definidos. Con esto evitamos aplicar modelos inadecuados (y perder tiempo/precisión) en series que por su naturaleza no van a responder bien.
Aumentar la precisión esperable y la estabilidad
Al alinear la familia de modelos con la “morfología” de la serie, reducimos errores sistemáticos (p. ej., ETS en una serie 70% ceros).
Los KPIs (prop_zeros, max_racha_0, adi_simple, cv_y) nos dan explicabilidad: podemos justificar por qué una serie fue a Croston y otra a ETS, con números y umbrales concretos.
Reducir el coste computacional y el tiempo de iteración
Evitamos lanzar búsquedas/ajustes complejos donde no aportan valor (p. ej., grid SARIMA en series casi todo ceros).
Priorizamos esfuerzos donde la ganancia es mayor (ruta GENERAL) y usamos algoritmos especializados y más ligeros para la ruta INTERMITENTE.
Subir el nivel de gobierno del dato (data governance)
Artefactos persistidos:
segmentation_intermitencia.csv (con KPIs y el label segment),
pairs_intermittentes.csv, pairs_generales.csv.
Trazabilidad: queda registrado en logs qué reglas aplicamos y qué tamaño tiene cada segmento.
Calidad y consistencia: el “Punto de control C” comprueba que la partición cubre todas las parejas del train (intermitentes + generales = total).
Preparar el terreno para reglas y fallbacks operativos
La segmentación alimenta directamente los árboles de decisión del modelado: qué probar primero, qué métricas priorizar, qué fallback usar si falla el ajuste principal.
También nos ayuda a gestionar riesgos: por ejemplo, marcar low_history si una serie tiene poco histórico y tratarla con cautela (o enviarla a una ruta conservadora).
Alinear diagnóstico, métricas y negocio
Con los tops (top 5 por prop_zeros, top 5 por max_racha_0) identificamos casos extremos para revisión humana (posibles errores de captura, cambios de contrato, etc.).
Esta lectura rápida, más los resúmenes del Paso 1, nos permite contar una historia de datos a negocio: cuántas series son regulares, cuántas son esporádicas, y qué implicaciones tiene eso en precisión y mantenimiento del sistema.
Mantener la separación estricta train/test
Todos los KPIs de segmentación se calculan solo con train (2021–2023). Respetamos la validez del test (2024) para una evaluación honesta en pasos posteriores.
Dejar listos los “inputs” del Paso 3
Salimos del Paso 2 con listas limpias y justificadas de parejas por ruta. Es decir, ya sabemos qué modelar, con qué familia, y por qué. Pasamos de “dataset indiferenciado” a backlog priorizado para el modelado.
En resumen: el Paso 2 convierte el caos en un plan. Etiquetamos cada serie con un criterio objetivo, trazable y reproducible, que nos permite asignar la técnica adecuada, mejorar la precisión prevista, acelerar el desarrollo y explicar nuestras decisiones con métricas simples
Bloque 1 - Parámetros, rutas y precondiciones¶
# ============================================
# Paso 2 — Segmentación por intermitencia
# Bloque 1) Parámetros, rutas y precondiciones
# ============================================
# Asumimos que vienen de pasos previos:
# - ruta_base_3, SUBFOLDERS, CSV_SEP
# - PAIR_COLS, DATECOL, VALUE_TRAINTEST
# - train_full (2021–2023, malla mensual con huecos a 0) y test_full (2024)
# - funciones ensure_dirs() y log()
# 1.1) Aseguramos subcarpetas y log dedicado del paso
ensure_dirs(ruta_base_3, SUBFOLDERS)
ruta_metricas = os.path.join(ruta_base_3, "METRICAS")
ruta_logs = os.path.join(ruta_base_3, "LOGS")
log_path_step2 = os.path.join(ruta_logs, "estrategia3_step2.log")
if not os.path.exists(log_path_step2):
with open(log_path_step2, "w", encoding="utf-8") as f:
f.write("== LOG Estrategia 3 - Paso 2 ==\n")
def log2(msg: str):
# Centralizamos el log específico del Paso 2 y también imprimimos en consola
print(msg)
with open(log_path_step2, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
log2("Iniciamos Paso 2 — Segmentación por intermitencia.")
# 1.2) Validamos que los dataframes canónicos están disponibles
assert "train_full" in globals(), "Falta train_full (generado en el Paso 0)."
assert "test_full" in globals(), "Falta test_full (generado en el Paso 0)."
# 1.3) Ordenamos copias de trabajo sin alterar originales
train_df = train_full.sort_values([*PAIR_COLS, DATECOL]).reset_index(drop=True).copy()
test_df = test_full .sort_values([*PAIR_COLS, DATECOL]).reset_index(drop=True).copy()
Iniciamos Paso 2 — Segmentación por intermitencia.
Bloque 2 - Funciones auxiliares de KPIs¶
# ===================================================
# Paso 2 — Bloque 2) Funciones auxiliares de KPIs
# ===================================================
def _rachas_de_ceros(y: np.ndarray):
# Detectamos rachas consecutivas de ceros en un vector 1D
# y devolvemos las longitudes de cada racha.
y = np.asarray(y, dtype=float)
is_zero = (y == 0)
if is_zero.size == 0:
return []
# Identificamos inicios y finales de rachas
dif = np.diff(is_zero.astype(int), prepend=0, append=0)
starts = np.where(dif == 1)[0]
ends = np.where(dif == -1)[0]
return list((ends - starts).astype(int))
def _coef_var_no_cero(y: np.ndarray):
# Calculamos el coeficiente de variación solo sobre valores > 0.
# Si hay menos de 2 valores no cero, devolvemos NaN.
y = np.asarray(y, dtype=float)
nz = y[y > 0]
if nz.size < 2:
return np.nan
mu = nz.mean()
if mu == 0:
return np.nan
return float(nz.std(ddof=1) / mu)
def _kpis_intermitencia(tr: pd.DataFrame, datecol: str, valcol: str):
# Calculamos los KPIs solicitados sobre un sub-dataframe de una pareja.
y = tr[valcol].astype(float).values
n_meses = len(y)
n_ceros = int((y == 0).sum())
prop_zeros = n_ceros / n_meses if n_meses > 0 else np.nan
rachas = _rachas_de_ceros(y)
max_racha_0 = int(max(rachas)) if len(rachas) > 0 else 0
adi_simple = float(np.mean(rachas)) if len(rachas) > 0 else 0.0
cv_y = _coef_var_no_cero(y)
return {
"n_meses_train": n_meses,
"n_ceros": n_ceros,
"prop_zeros": prop_zeros,
"max_racha_0": max_racha_0,
"adi_simple": adi_simple,
"cv_y": cv_y
}
Bloque 3 - Cálculo de KPIs y segmentación¶
# ===================================================
# Paso 2 — Bloque 3) Cálculo de KPIs y segmentación
# ===================================================
# 3.1) Panel de parejas en train
pairs_train = train_df[PAIR_COLS].drop_duplicates().values.tolist()
# 3.2) Calculamos KPIs por pareja
rows = []
for bid, ctype in pairs_train:
sub = train_df[(train_df[PAIR_COLS[0]] == bid) & (train_df[PAIR_COLS[1]] == ctype)].copy()
sub = sub.sort_values(DATECOL)
k = _kpis_intermitencia(sub, DATECOL, VALUE_TRAINTEST)
# 3.3) Segmentamos con reglas pragmáticas
# - Si n_meses_train < 12, forzamos INTERMITENTE por cautela y lo dejamos registrado.
low_history = int(k["n_meses_train"] < 12)
if low_history:
segment = "INTERMITENTE"
else:
segment = "INTERMITENTE" if (k["prop_zeros"] >= 0.35 or k["max_racha_0"] >= 4) else "GENERAL"
rows.append({
"ID_BUILDING": bid,
"FM_COST_TYPE": ctype,
**k,
"segment": segment,
"low_history": low_history
})
seg_df = pd.DataFrame(rows)
# 3.4) Guardamos el CSV de segmentación
path_seg = os.path.join(ruta_metricas, "segmentation_intermitencia.csv")
seg_df.to_csv(path_seg, sep=CSV_SEP, index=False)
log2(f"Guardado: {path_seg}")
# 3.5) Construimos listas de trabajo
pairs_intermittentes = seg_df.loc[seg_df["segment"] == "INTERMITENTE", PAIR_COLS].drop_duplicates().copy()
pairs_generales = seg_df.loc[seg_df["segment"] == "GENERAL", PAIR_COLS].drop_duplicates().copy()
# 3.6) Reutilizamos pairs_solo_test del Paso 0 si existe; si no, lo recomputamos
try:
# Intentamos detectar si ya existe un CSV del Paso 0
path_solo_test = os.path.join(ruta_metricas, "pairs_solo_test.csv")
if os.path.exists(path_solo_test):
pairs_solo_test = pd.read_csv(path_solo_test, sep=CSV_SEP)
else:
# Recomputamos a partir de train_df y test_df
s_train = set(map(tuple, train_df[PAIR_COLS].drop_duplicates().values.tolist()))
s_test = set(map(tuple, test_df [PAIR_COLS].drop_duplicates().values.tolist()))
only_test = sorted(list(s_test - s_train))
pairs_solo_test = pd.DataFrame(only_test, columns=PAIR_COLS)
pairs_solo_test.to_csv(path_solo_test, sep=CSV_SEP, index=False)
log2(f"Guardado (recomputado): {path_solo_test}")
except Exception as e:
log2(f"AVISO: no fue posible cargar/recomputar pairs_solo_test ({e}).")
pairs_solo_test = pd.DataFrame(columns=PAIR_COLS)
# 3.7) Guardamos listas de trabajo
path_interm = os.path.join(ruta_metricas, "pairs_intermittentes.csv")
path_gener = os.path.join(ruta_metricas, "pairs_generales.csv")
pairs_intermittentes.to_csv(path_interm, sep=CSV_SEP, index=False)
pairs_generales.to_csv(path_gener, sep=CSV_SEP, index=False)
log2(f"Guardado: {path_interm}")
log2(f"Guardado: {path_gener}")
Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/segmentation_intermitencia.csv Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/pairs_intermittentes.csv Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/pairs_generales.csv
Bloque 4 - Punto de control C y diagnósticos¶
# ======================================================
# Paso 2 — Bloque 4) Punto de control C y diagnósticos
# ======================================================
print("== Paso 2 — Segmentación por intermitencia ==")
total_train_pairs = seg_df[PAIR_COLS].drop_duplicates().shape[0]
n_intermittentes = pairs_intermittentes.shape[0]
n_generales = pairs_generales.shape[0]
n_solo_test = pairs_solo_test.shape[0] if isinstance(pairs_solo_test, pd.DataFrame) else 0
print(f"Total_train_pairs: {total_train_pairs}")
print(f"n_intermittentes : {n_intermittentes}")
print(f"n_generales : {n_generales}")
print(f"n_solo_test : {n_solo_test} # informativo (no suma con train)")
log2(f"Total_train_pairs: {total_train_pairs}")
log2(f"n_intermittentes : {n_intermittentes}")
log2(f"n_generales : {n_generales}")
log2(f"n_solo_test : {n_solo_test}")
# 4.1) Verificación identidad
assert (n_intermittentes + n_generales) == total_train_pairs, \
"La suma de intermitentes y generales no cuadra con el total del train."
print("Comprobación -> n_intermittentes + n_generales == Total_train_pairs: OK")
log2("Comprobación -> n_intermittentes + n_generales == Total_train_pairs: OK")
# 4.2) Tops de casos límite
top_prop = (seg_df
.sort_values("prop_zeros", ascending=False)
[PAIR_COLS + ["prop_zeros","max_racha_0"]]
.head(5))
top_racha = (seg_df
.sort_values("max_racha_0", ascending=False)
[PAIR_COLS + ["max_racha_0","prop_zeros"]]
.head(5))
print("-- Top 5 por prop_zeros --")
for _, r in top_prop.iterrows():
print((r["ID_BUILDING"], r["FM_COST_TYPE"], float(r["prop_zeros"]), int(r["max_racha_0"])))
print("-- Top 5 por max_racha_0 --")
for _, r in top_racha.iterrows():
print((r["ID_BUILDING"], r["FM_COST_TYPE"], int(r["max_racha_0"]), float(r["prop_zeros"])))
# 4.3) Mensajes finales
print(f"Guardado: {path_seg}")
print(f"Guardado: {path_interm}")
print(f"Guardado: {path_gener}")
log2("Paso 2 finalizado.")
== Paso 2 — Segmentación por intermitencia == Total_train_pairs: 2429 n_intermittentes : 1397 n_generales : 1032 n_solo_test : 461 # informativo (no suma con train) Total_train_pairs: 2429 n_intermittentes : 1397 n_generales : 1032 n_solo_test : 461 Comprobación -> n_intermittentes + n_generales == Total_train_pairs: OK Comprobación -> n_intermittentes + n_generales == Total_train_pairs: OK -- Top 5 por prop_zeros -- (9, 'Eficiencia Energética', 1.0, 36) (18, 'Eficiencia Energética', 1.0, 36) (2, 'Eficiencia Energética', 1.0, 36) (1060, 'Servicios Extra', 1.0, 36) (1065, 'Eficiencia Energética', 1.0, 36) -- Top 5 por max_racha_0 -- (1306, 'Obras', 36, 1.0) (62, 'Licencias', 36, 1.0) (9, 'Eficiencia Energética', 36, 1.0) (2, 'Eficiencia Energética', 36, 1.0) (18, 'Eficiencia Energética', 36, 1.0) Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/segmentation_intermitencia.csv Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/pairs_intermittentes.csv Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/pairs_generales.csv Paso 2 finalizado.
Interpretación paso 2:¶
Tamaño y proporciones del segmento
Confirmamos 2.429 parejas en train. De ellas, 1.397 son INTERMITENTES (57,5%) y 1.032 GENERALES (42,5%). La identidad cuadra y el Punto C está OK.
Hay 461 SOLO TEST que no entran en esta segmentación porque carecen de histórico y deberán ir con fallback.
Implicaciones directas para el Paso 3
Priorizaremos dos rutas:
INTERMITENTE (57,5%) → Croston/SBA/IMAPA y variantes.
GENERAL (42,5%) → pool ETS/ARIMA/Holt con reglas y fallbacks.
Este reparto nos permite concentrar el esfuerzo “pesado” en el 42,5% y usar métodos específicos más ligeros y adecuados en el 57,5%.
Señales fuertes del top de intermitencia
Vemos múltiples casos con prop_zeros = 1.0 y max_racha_0 = 36 (p. ej., Eficiencia Energética en varios edificios, Obras y Licencias en algunos casos).
Esto nos indica cero gasto sostenido en todo 2021–2023. Para estas series, proponemos:
Modelar con pronóstico estructural cero como baseline operativo (y documentarlo).
Activar una regla de alerta si 2024 muestra actividad repentina para forzar revisión humana o cambiar de ruta.
Mantenerlas fuera de búsquedas complejas (ETS/SARIMA no aportará valor).
Calidad del dato y gobierno
Al detectar rachas completas de 36 ceros, abrimos dos hipótesis: i) gasto realmente nulo (política/contrato), ii) posible subregistro o cambio de codificación.
Sugerimos verificar con negocio si categorías como “Eficiencia Energética” deben tener gasto esperado; si sí, revisamos la captura o mapeo del concepto.
Consecuencias en métricas y fiabilidad
Al separar correctamente las intermitentes, esperamos:
Mejor SMAPE/WAPE frente a aplicar ETS/Holt a series casi todo ceros.
MASE12 más coherente con la naturaleza de cada serie (evitamos escalas distorsionadas por ceros).
En la ruta GENERAL, podremos seguir apalancando lo aprendido del Paso 1 (SNaive12, Mediana estacional) como benchmarks y fallbacks.
Qué haríamos a continuación (checklist del Paso 3)
Para INTERMITENTE:
Excluir las todo-cero (36 ceros) y fijar pronóstico base = 0 con monitorización.
En el resto, ejecutar Croston/SBA/IMAPA y comparar contra Mediana estacional y SNaive12 como referencias.
Para GENERAL:
Entrenar Holt/ETS/ARIMA guiados por diagnóstico previo; comparar con baselines del Paso 1.
Mantener reglas de fallback cuando MASE12/SMAPE superen umbrales pactados.
Artefactos listos y trazabilidad
Ya contamos con segmentation_intermitencia.csv, pairs_intermittentes.csv y pairs_generales.csv; los usaremos como listas de orquestación del modelado y para reporting.
Dejamos constancia en el log del paso, lo que facilita auditoría y reproducibilidad.
Paso 3 — Conjunto de modelos candidato¶
El Paso 3 tiene como propósito convertir la segmentación de series en un plan concreto de modelos a aplicar, con reglas claras, criterios de fallback, trazabilidad y estilo uniforme de implementación, preparando el terreno para que el Paso 4 se limite a ejecutar y evaluar de manera ordenada.
Bajamos a un nivel operativo y práctico lo que hasta ahora teníamos solo como idea metodológica. En otras palabras:
Claridad de objetivos
Dejamos escrito que el Paso 3 no busca aún entrenar modelos, sino definir y listar el menú de modelos candidatos que probaremos en el Paso 4.
Esto evita confusión: en este paso todavía no hay entrenamiento masivo, solo diseño de rutas (GENERAL vs INTERMITENTE) y fijación de reglas.
Separación de rutas según el tipo de serie
Las conclusiones del Paso 2 nos obligan a diferenciar tratamientos.
El detalle asegura que cada ruta tenga su set de modelos, baselines y reglas de fallback ya definidos.
Esto simplifica la orquestación del Paso 4 porque no tendremos que improvisar qué modelo probar según el caso: ya está preconfigurado.
Estandarización y trazabilidad
El detalle de baselines, métricas (SMAPE, WAPE, MASE), umbrales de error y logs asegura que todas las decisiones estén justificadas y reproducibles.
Así, cualquier auditoría del proceso puede seguir qué modelos se intentaron, por qué se descartaron y qué fallback se aplicó.
Gobernanza del proceso
Definir desde ya que usaremos pairs_intermittentes.csv y pairs_generales.csv como listas de control, o que generaremos un log estrategia3_step3.log, marca un protocolo de gobierno de datos y modelos.
Esto conecta con la filosofía de reproducibilidad que pide el proyecto.
Estilo de implementación en código
Al exigir comentarios en primera persona del plural (# en este bloque definimos los parámetros...), sin iconos, y con bloques separados para funciones, parámetros y ejecución, conseguimos:
Un código más limpio y entendible.
Una guía didáctica para nosotros y para cualquier miembro del equipo que lea el notebook.
También facilita que, cuando ejecutemos, podamos interpretar juntos los outputs en el mismo estilo narrativo, sin ruido ni adornos.
Bloque 1 — Parámetros, rutas, artefactos y logging¶
# ============================================================
# Paso 3 — Conjunto de modelos candidato
# Bloque 1) Parámetros, rutas, artefactos y logging
# ============================================================
# Definimos valores por defecto por si no vienen del Paso 2
# Explicación: preferimos ser robustos a la hora de ejecutar el notebook de forma independiente.
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
SUBFOLDERS = globals().get("SUBFOLDERS", ["METRICAS", "LOGS", "CONFIG"])
# Rutas de trabajo
ruta_metricas = os.path.join(ruta_base_3, "METRICAS")
ruta_logs = os.path.join(ruta_base_3, "LOGS")
ruta_config = os.path.join(ruta_base_3, "CONFIG")
# Aseguramos subcarpetas de salida
def ensure_dirs(base_path: str, subfolders: list):
# Creamos las carpetas de salida para mantener la trazabilidad del paso
os.makedirs(base_path, exist_ok=True)
for sf in subfolders:
os.makedirs(os.path.join(base_path, sf), exist_ok=True)
ensure_dirs(ruta_base_3, SUBFOLDERS)
# Logging del paso 3
log_path_step3 = os.path.join(ruta_logs, "estrategia3_step3.log")
if not os.path.exists(log_path_step3):
with open(log_path_step3, "w", encoding="utf-8") as f:
f.write("== LOG Estrategia 3 - Paso 3 ==\n")
def log3(msg: str):
# Centralizamos el log específico del Paso 3 y también imprimimos en consola
print(msg)
with open(log_path_step3, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
log3("Iniciamos Paso 3 — Conjunto de modelos candidato.")
# Artefactos esperados desde el Paso 2
path_seg = os.path.join(ruta_metricas, "segmentation_intermitencia.csv")
path_interm= os.path.join(ruta_metricas, "pairs_intermittentes.csv")
path_gener = os.path.join(ruta_metricas, "pairs_generales.csv")
# Configuración de umbrales y métricas de decisión
# Explicación: fijamos los criterios de corte que utilizaremos en el Paso 4 para seleccionar y hacer fallback.
METRIC_PRIMARY = "SMAPE" # métrica principal para selección local por pareja
METRICS_TRACK = ["SMAPE", "WAPE", "MASE12"] # métricas que vamos a registrar
THRESHOLDS_FAIL = {"MASE12": 1.20, "SMAPE": 25.0} # disparadores de fallback
H = 12 # horizonte mensual estándar
Iniciamos Paso 3 — Conjunto de modelos candidato.
Bloque 2 — Funciones auxiliares y definición del “menú” de modelos¶
# ============================================================
# Paso 3 — Conjunto de modelos candidato
# Bloque 2) Funciones auxiliares y definición del menú
# ============================================================
# Leemos listas de parejas y segmentación
def load_artifacts():
# Cargamos los artefactos del Paso 2 y validamos su consistencia mínima
seg_df = pd.read_csv(path_seg, sep=CSV_SEP)
pairs_intermittentes = pd.read_csv(path_interm, sep=CSV_SEP)
pairs_generales = pd.read_csv(path_gener, sep=CSV_SEP)
# Validamos columnas esenciales
req_cols = ["ID_BUILDING", "FM_COST_TYPE"]
for df_name, df_obj in [("segmentation_intermitencia.csv", seg_df),
("pairs_intermittentes.csv", pairs_intermittentes),
("pairs_generales.csv", pairs_generales)]:
missing = set(req_cols) - set(df_obj.columns)
assert not missing, f"Faltan columnas {missing} en {df_name}"
return seg_df, pairs_intermittentes, pairs_generales
# Identificamos series todo-cero en train para baseline estructural 0
def detect_all_zero_series(seg_df: pd.DataFrame) -> pd.DataFrame:
# Consideramos max_racha_0 == 36 como proxy de 36 meses seguidos a cero en 2021–2023
# Guardamos bandera para orquestación posterior
df = seg_df.copy()
df["flag_all_zero_train"] = (df.get("max_racha_0", 0).astype(int) >= 36).astype(int)
return df
# Definimos el menú de modelos por ruta
def build_menu_general():
# Definimos el menú de candidatos para series generales
modelos = {
"candidatos": [
{"name": "Theta", "family": "theta", "params": {}},
{"name": "ETS_auto", "family": "ets", "params": {"auto": True, "components": ["A/A", "A/M", "M/M"]}},
{"name": "ARIMA_auto", "family": "arima", "params": {"grid": "light"}},
{"name": "Holt_damped", "family": "holt", "params": {"damped": True}},
],
"baselines": [
{"name": "Naive1", "family": "baseline", "params": {"type": "naive1"}},
{"name": "SNaive12", "family": "baseline", "params": {"type": "snaive12"}},
{"name": "MedianaEstac", "family": "baseline", "params": {"type": "median_seasonal", "m": 12}},
],
"rules": {
"diagnostico_guiado": True,
"fallback_if": THRESHOLDS_FAIL, # usamos los umbrales definidos en Bloque 1
"metric_primary": METRIC_PRIMARY,
"metrics_track": METRICS_TRACK,
}
}
return modelos
def build_menu_intermittent():
# Definimos el menú de candidatos para series intermitentes
modelos = {
"candidatos": [
{"name": "Croston", "family": "croston", "params": {"variant": "classic"}},
{"name": "SBA", "family": "croston", "params": {"variant": "sba"}},
{"name": "TSB", "family": "tsb", "params": {"alpha_grid": [0.1, 0.3, 0.5]}},
{"name": "ADIDA_MAPA", "family": "adida_mapa", "params": {
"agg_level": "Q", # trimestral
"model_low_freq": ["ETS_auto", "Theta"],
"disagg": "perfil_historico"
}},
],
"baselines": [
{"name": "SNaive12", "family": "baseline", "params": {"type": "snaive12"}},
{"name": "PerfilEscaso", "family": "baseline", "params": {"type": "perfil_escaso_uniforme", "m": 12}},
],
"rules": {
"baseline_cero_if_all_zero_train": True,
"metric_primary": METRIC_PRIMARY,
"metrics_track": METRICS_TRACK,
}
}
return modelos
# Construimos un diccionario de configuración global del Paso 3
def build_step3_config(seg_df: pd.DataFrame):
# Creamos un paquete de configuración serializable a JSON para el Paso 4
config = {
"horizon": H,
"metrics": {
"primary": METRIC_PRIMARY,
"track": METRICS_TRACK,
"thresholds_fail": THRESHOLDS_FAIL
},
"routes": {
"GENERAL": build_menu_general(),
"INTERMITENTE": build_menu_intermittent()
},
"governance": {
"lists_control": {
"pairs_intermittentes_csv": os.path.relpath(path_interm, ruta_base_3),
"pairs_generales_csv": os.path.relpath(path_gener, ruta_base_3),
"segmentation_csv": os.path.relpath(path_seg, ruta_base_3),
},
"logs": {
"step3_log": os.path.relpath(log_path_step3, ruta_base_3)
}
},
"flags": {
"all_zero_flag_column": "flag_all_zero_train"
}
}
# Añadimos resumen de poblaciones por segmento para trazabilidad
total_pairs = seg_df[["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0]
n_interm = seg_df[seg_df["segment"] == "INTERMITENTE"][["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0]
n_general = seg_df[seg_df["segment"] == "GENERAL"][["ID_BUILDING","FM_COST_TYPE"]].drop_duplicates().shape[0]
config["population"] = {
"total_train_pairs": int(total_pairs),
"n_intermittentes": int(n_interm),
"n_generales": int(n_general)
}
return config
Bloque 3 — Ejecución: carga de artefactos, marcado de flags y guardado de configuración¶
# ============================================================
# Paso 3 — Conjunto de modelos candidato
# Bloque 3) Ejecución y guardado de artefactos de configuración
# ============================================================
# Cargamos artefactos del Paso 2
seg_df, pairs_intermittentes, pairs_generales = load_artifacts()
log3("Artefactos del Paso 2 cargados correctamente.")
# Marcamos series todo-cero en train
seg_df = detect_all_zero_series(seg_df)
flag_count = int(seg_df["flag_all_zero_train"].sum())
log3(f"Series con flag_all_zero_train=1 detectadas: {flag_count}")
# Guardamos la segmentación enriquecida con la bandera
path_seg_enriched = os.path.join(ruta_metricas, "segmentation_intermitencia_step3.csv")
seg_df.to_csv(path_seg_enriched, sep=CSV_SEP, index=False)
log3(f"Guardado: {path_seg_enriched}")
# Construimos la configuración del Paso 3
step3_config = build_step3_config(seg_df)
# Guardamos la configuración como JSON para que el Paso 4 pueda orquestar entrenamientos
path_cfg_json = os.path.join(ruta_config, "step3_menu_modelos_config.json")
with open(path_cfg_json, "w", encoding="utf-8") as f:
json.dump(step3_config, f, ensure_ascii=False, indent=2)
log3(f"Guardado: {path_cfg_json}")
# Resumen en consola para verificación rápida
print("== Resumen Paso 3 — Menú de modelos candidato ==")
print(f"Horizonte: {step3_config['horizon']}")
print("Métrica primaria:", step3_config["metrics"]["primary"])
print("Métricas a trazar:", step3_config["metrics"]["track"])
print("Umbrales de fallback:", step3_config["metrics"]["thresholds_fail"])
print("\nRutas definidas:")
for route in step3_config["routes"].keys():
print(f" - {route}")
print("\nListas de control:")
print(step3_config["governance"]["lists_control"])
print("\nPoblación por segmento:")
print(step3_config["population"])
log3("Paso 3 finalizado. Menú de modelos candidato definido y configurado.")
Artefactos del Paso 2 cargados correctamente. Series con flag_all_zero_train=1 detectadas: 104 Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/segmentation_intermitencia_step3.csv Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/CONFIG/step3_menu_modelos_config.json == Resumen Paso 3 — Menú de modelos candidato == Horizonte: 12 Métrica primaria: SMAPE Métricas a trazar: ['SMAPE', 'WAPE', 'MASE12'] Umbrales de fallback: {'MASE12': 1.2, 'SMAPE': 25.0} Rutas definidas: - GENERAL - INTERMITENTE Listas de control: {'pairs_intermittentes_csv': 'METRICAS/pairs_intermittentes.csv', 'pairs_generales_csv': 'METRICAS/pairs_generales.csv', 'segmentation_csv': 'METRICAS/segmentation_intermitencia.csv'} Población por segmento: {'total_train_pairs': 2429, 'n_intermittentes': 1397, 'n_generales': 1032} Paso 3 finalizado. Menú de modelos candidato definido y configurado.
Interpretación paso 3¶
- Contexto
En el proyecto de predicción de OPEX FM, el Paso 2 clasificó las series en intermitentes (57,5%) y generales (42,5%), generando los artefactos necesarios.
El Paso 3 debía definir un “menú de modelos candidatos” para cada tipo de serie, con reglas de fallback claras y trazabilidad.
- Qué nos dicen los resultados
Artefactos cargados correctamente: la segmentación y las listas de pares quedaron bien enlazadas.
Series todo-cero: se detectaron 104 parejas con 36 meses consecutivos sin gasto, que quedan marcadas con flag_all_zero_train. Estas no se modelarán con algoritmos complejos, sino con baseline fijo = 0 y alerta si aparece actividad.
Segmentación enriquecida guardada: segmentation_intermitencia_step3.csv ahora incluye la nueva columna de bandera.
Configuración JSON creada: step3_menu_modelos_config.json resume todo el menú de modelos, métricas y umbrales, y servirá de entrada directa al Paso 4.
- Resumen de la configuración
Horizonte de predicción: 12 meses.
Métrica primaria: SMAPE.
Métricas a seguir: SMAPE, WAPE, MASE12.
Umbrales de fallback: MASE12 > 1.2 o SMAPE > 25% -> activa fallback.
Rutas definidas:
GENERAL -> Theta, ETS(auto), ARIMA(auto), Holt amortiguado.
INTERMITENTE -> Croston/SBA, TSB, ADIDA/MAPA.
Baselines incluidos en ambos casos para comparar y como red de seguridad.
Gobernanza: se registran los CSV de control y logs asociados.
Distribución de la población:
Total train pairs = 2.429.
Intermitentes = 1.397 (57,5%).
Generales = 1.032 (42,5%).
- Interpretación estratégica
Hemos pasado de la segmentación a tener dos rutas de modelado claramente diferenciadas, con sus reglas, métricas y fallback.
La gran mayoría (57,5%) se abordará con técnicas de series intermitentes, que son más ligeras y adaptadas a gasto esporádico.
El 42,5% restante, más estable, recibirá los modelos “pesados” (ETS, ARIMA, Holt, Theta).
Las series todo-cero (104 casos) quedan tratadas de forma específica, lo que evita perder tiempo en entrenamientos inútiles.
La existencia de un JSON de configuración garantiza que el Paso 4 pueda orquestar los experimentos de forma reproducible y automatizada.
Paso 4 - Validación “rolling origin” en 2023 y selección top-2 por serie¶
El propósito del paso 4 es validar, de forma rápida pero representativa, qué modelos funcionan mejor por serie usando ventanas móviles dentro de 2023, resumir su rendimiento medio, y quedarnos con un top-2 por pareja. Si nada supera al referente operativo (SNaive12), nos quedamos con ese baseline como fallback. Todo ello, con métricas homogéneas y trazabilidad lista para auditoría.
- Por qué “rolling origin” corto en 2023
Representatividad temporal: 2023 es el año inmediatamente anterior al horizonte de predicción (2024). Validar ahí reduce riesgo de “drift” respecto al patrón más reciente.
Tamaño de muestra suficiente pero barato: 2–3 orígenes (abr, ago, nov) dan variedad estacional (primavera−verano−otoño) sin disparar el cómputo.
Evitar sobreajuste al último mes: con varios orígenes comprobamos que el modelo no “acierta” por casualidad en una sola foto.
En cada origen entrenamos hasta el mes anterior y predecimos h pasos (h=1 siempre, h=3 si hay tiempo). Así obtenemos errores prospectivos “como si” hubiéramos estado en esa fecha.
- Métrica primaria y por qué MASE12
MASE12 compara el error medio absoluto del modelo con el error medio de una naive estacional (m=12) calculada sobre el train de esa ventana.
Interpretación: ≈1 implica rendimiento similar a SNaive12; <1 es mejor que la naive estacional; >1 es peor.
SMAPE y WAPE se registran como métricas complementarias:
SMAPE aporta escala relativa simétrica (evita penalizar asimétricamente sobre/infra-predicción).
WAPE es intuitiva (porcentaje de error ponderado por el volumen real).
Usar MASE12 como regla de ordenación alinea la selección con lo aprendido en el Paso 1 y con vuestro benchmark operativo.
- Entradas necesarias (por qué son “duras”)
train_full/test_full: series mensuales canónicas y limpias (sin duplicados, huecos a 0). Son la base fiable de cualquier ventana rolling.
Segmentación (intermitente/general) + flag_all_zero_train: evita entrenar modelos inadecuados (p. ej., ETS sobre series todo-cero).
Menú JSON del Paso 3: centraliza qué modelos probar por ruta, con parámetros y umbrales. Asegura reproducibilidad.
Métricas de baselines 2024: nos dan el listón real de SNaive12 para decidir si la mejora es material.
- Salidas que te deja listas para decidir
rolling_metrics_raw.csv: el “diario” de la validación (serie, modelo, origen, horizonte, métricas). Útil para auditoría y análisis fino.
rolling_metrics_agg.csv: media por serie-modelo (agregando orígenes). Es la tabla para ordenar candidatos con criterio estable.
seleccion_modelos_cv.csv: la decisión operativa por pareja: modelo_top1, modelo_top2, y si realmente mejoramos a SNaive12 (con el delta de MASE12).
Punto de control D: chequea % de series que mejoran al baseline. Si pocas lo hacen, nos guía a dos reacciones:
Reducir el menú a lo que mejor rinde (p. ej., Theta/ETS) para ganar estabilidad y tiempo.
Tratar outliers (mod_3) y revalidar solo las peores series.
- Orquestación por rutas (por qué y cómo)
INTERMITENTE: Croston/SBA/TSB/ADIDA-MAPA + baselines específicos. Diseñados para ceros frecuentes y demanda esporádica.
flag_all_zero_train=1: no se entrena; se fija baseline_cero. Evita cómputo inútil y errores de sobreajuste.
GENERAL: Theta, ETS(auto), ARIMA(auto), Holt(damped) + baselines. Cubren tendencia/estacionalidad estándar.
Empates: desempate por WAPE_mean tras MASE12_mean, combinando robustez (MASE) con interpretabilidad (WAPE).
- Por qué comparar contra SNaive12 “del 2024”
Aunque la validación ocurre en 2023, la decisión final debe considerar cómo le fue a SNaive12 en 2024, que es vuestro referente real del horizonte objetivo. Por eso calculamos:
delta_MASE12_vs_SNaive12 = MASE12_top1 (rolling) − MASE12_SNaive12_2024.
Si delta < 0, nuestro top1 es mejor que lo que vimos de SNaive12 en 2024. Si no, fallback.
Esto mezcla una validación proxy (rolling 2023) con el benchmark operativo real (SNaive12 en 2024), que es pragmático para decidir.
- Interfaz homogénea de modelos (beneficio)
fit_predict(model_spec, ...) impone una API única para todas las familias (theta, ets, arima, holt, croston, tsb, adida_mapa, baseline). Ventajas:
Cambiar/añadir modelos sin tocar el bucle principal.
Control centralizado de parámetros y reproducibilidad (se leen del JSON).
- Reutilizar métricas del Paso 1 (consistencia)
Misma implementación de MAE/WAPE/SMAPE/MASE ⇒ métricas comparables entre pasos.
Escalas MASE siempre desde el train de la ventana ⇒ evita fugas de información.
- Logs y gobernanza (por qué es clave)
estrategia3_step4.log deja: población por rutas, cobertura por horizonte, % de mejora vs SNaive12, y lista de problemáticas por umbrales.
Esto permite:
Auditar qué modelos se probaron y con qué resultado.
Reaccionar rápido: reducir menú, tratar outliers, re-correr subconjuntos.
- Controles de calidad (para que no nos engañen los números)
Sanity checks típicos (SNaive12 no debe ser batida por Naive1 en series estacionales; SNaive12 ≈ 1 en MASE12 si la estacionalidad es clara).
Cobertura de orígenes (1–3 válidos) y columnas requeridas sin NaN en los CSV finales.
- Cómo leer los resultados (qué mirar primero)
% winner_beats_snaive12: ¿cuánto mejoramos al baseline?
seleccion_modelos_cv.csv: ¿quién gana por pareja y con qué margen (delta)?
Ranking de modelos en rolling_metrics_agg.csv: ¿qué familias son más sólidas globalmente?
Lista de series malas por umbrales: candidatas a mod_3 (tratamiento de outliers) o a reducir menú.
- Cómo encaja con el Paso 5 (o explotación)
modelo_top1 y modelo_top2 por pareja son la entrada directa para:
Entrenar definitivo con todo 2021–2023 y predecir 2024 (o el nuevo horizonte).
Diseñar fallbacks automáticos si el top1 falla en alguna validación online (usar top2 o SNaive12).
Bloque 1 - Parámetros y rutas¶
# ============================================================
# ESTRATEGIA 3 — PASO 4
# Validación “rolling origin” en 2023 y selección top-2 por serie
# ============================================================
# En este paso implementamos:
# - Una validación rolling con orígenes en 2023-04-01, 2023-08-01 y 2023-11-01.
# - Evaluación con h=1 (y opcional h=3).
# - Cálculo de métricas MASE12 (primaria), WAPE y SMAPE.
# - Agregación por serie-modelo, selección top-2 y comparación contra SNaive12 (2024).
# - Persistencia de CSVs y logging de diagnóstico (Punto de control D).
# ============================================================
# =========================
# Bloque 1) Parámetros y rutas
# =========================
# Definimos rutas base y archivos esperados de pasos anteriores.
# Si ejecutamos el Paso 4 de forma aislada, tomamos valores por defecto.
# Fijamos semilla global para reproducibilidad de cualquier aleatoriedad interna.
np.random.seed(7)
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
RUTA_CONFIG = os.path.join(ruta_base_3, "CONFIG")
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
os.makedirs(RUTA_CONFIG, exist_ok=True)
LOG_PATH_STEP4 = os.path.join(RUTA_LOGS, "estrategia3_step4.log")
# Parámetros de rolling origin y horizontes
DO_H3 = False
ORIGINS = ["2023-04-01", "2023-08-01", "2023-11-01"] # MS
H_LIST = [1] + ([3] if DO_H3 else [])
# Columnas canónicas y frecuencia
PAIR_COLS = ["ID_BUILDING", "FM_COST_TYPE"]
DATECOL = "FECHA"
VALCOL = "cost_float_mod"
FREQ = "MS"
# Archivos de entrada obligatorios (pasos previos)
PATH_SEG_ENR = os.path.join(RUTA_METRICAS, "segmentation_intermitencia_step3.csv")
PATH_PAIRS_INT = os.path.join(RUTA_METRICAS, "pairs_intermittentes.csv")
PATH_PAIRS_GEN = os.path.join(RUTA_METRICAS, "pairs_generales.csv")
PATH_CFG_JSON = os.path.join(RUTA_CONFIG, "step3_menu_modelos_config.json")
PATH_BASELINES_2024 = os.path.join(RUTA_METRICAS, "metrics_baselines_cv.csv")
# Archivos de salida de este paso
PATH_RAW = os.path.join(RUTA_METRICAS, "rolling_metrics_raw.csv")
PATH_AGG = os.path.join(RUTA_METRICAS, "rolling_metrics_agg.csv")
PATH_SEL = os.path.join(RUTA_METRICAS, "seleccion_modelos_cv.csv")
Bloque 2 - Utilidades de IO y logging¶
# =========================
# Bloque 2) Utilidades de IO y logging
# =========================
def log4(msg: str):
# Registramos mensajes del paso 4 en archivo y consola para trazabilidad.
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP4, "a", encoding="utf-8") as f:
f.write(line + "\n")
def _ensure_dtindex(df: pd.DataFrame, datecol: str):
# Aseguramos que la columna fecha esté en tipo datetime64 y sea MS.
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _concat_train_test(train_df: pd.DataFrame, test_df: pd.DataFrame) -> pd.DataFrame:
# Construimos una serie continua 2021–2024 para poder evaluar cruces de frontera (nov->dic->ene).
df = pd.concat([train_df, test_df], ignore_index=True)
df = _ensure_dtindex(df, DATECOL)
df = df.sort_values([*PAIR_COLS, DATECOL]).reset_index(drop=True)
return df
Bloque 3 - Métricas (reusamos del criterio del Paso 1)¶
# =========================
# Bloque 3) Métricas (reusamos criterio del Paso 1)
# =========================
def _safe_div(num, den, eps=1e-8):
return num / (den + eps)
def _mae(y_true, y_pred):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(np.mean(np.abs(y_pred - y_true)))
def _wape_safe(y_true, y_pred, eps=1e-8):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.sum(np.abs(y_pred - y_true))
den = np.sum(np.abs(y_true))
return float(_safe_div(num, den, eps))
def _smape_safe(y_true, y_pred, eps=1e-8):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = (np.abs(y_true) + np.abs(y_pred)) / 2.0
return float(np.mean(_safe_div(num, den, eps)) * 100.0)
def _scale_diff(series, lag):
arr = np.asarray(series, dtype=float)
if len(arr) <= lag:
return np.nan
diffs = np.abs(arr[lag:] - arr[:-lag])
if len(diffs) == 0:
return np.nan
return float(np.mean(diffs))
def mase_from_scale(errors, scale):
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
errors = np.asarray(errors, dtype=float)
return float(np.mean(np.abs(errors)) / float(scale))
def _eval_series(y_true, y_pred, y_train_for_scales):
# Calculamos métricas con escalas MASE sobre el train de la ventana.
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
err = y_pred - y_true
scale1 = _scale_diff(y_train_for_scales, lag=1)
scale12 = _scale_diff(y_train_for_scales, lag=12)
return {
"MAE": _mae(y_true, y_pred),
"WAPE": _wape_safe(y_true, y_pred),
"SMAPE": _smape_safe(y_true, y_pred),
"MASE1": mase_from_scale(err, scale1),
"MASE12": mase_from_scale(err, scale12)
}
Bloque 4 - Wrappers de modelos¶
# =========================
# Bloque 4) Wrappers de modelos
# =========================
# Definimos una API común: fit_predict(model_spec, y_train, dates_train, horizon, freq="MS")
# Implementamos baselines y modelos frecuentes. Dejamos ARIMA/ETS/Theta protegidos si faltan deps.
def _baseline_naive1(y_train, h):
# Repetimos el último valor observado.
return np.repeat(float(y_train[-1]) if len(y_train) else 0.0, h)
def _baseline_snaive12(y_train, dates_train, h):
# Usamos el valor del mismo mes del año anterior si existe; si no, caemos al último valor.
if len(y_train) == 0:
return np.zeros(h, dtype=float)
# Construimos un mapa mes->valor último año observado
df = pd.DataFrame({"FECHA": dates_train, "y": y_train})
df["MONTH_NUM"] = pd.to_datetime(df["FECHA"]).dt.month
# Tomamos el último año completo disponible en el train
df["YEAR"] = pd.to_datetime(df["FECHA"]).dt.year
last_year = df["YEAR"].max()
map_last_year = (df[df["YEAR"] == last_year].set_index("MONTH_NUM")["y"].to_dict())
# Generamos meses futuros consecutivos
last_date = pd.to_datetime(dates_train[-1]).to_period("M").to_timestamp()
future = pd.date_range(last_date + pd.offsets.MonthBegin(1), periods=h, freq="MS")
pred = []
for d in future:
m = d.month
if m in map_last_year:
pred.append(map_last_year[m])
else:
pred.append(float(y_train[-1]))
return np.array(pred, dtype=float)
def _baseline_median_seasonal(y_train, dates_train, h, m=12):
# Tomamos la mediana por mes sobre el train y proyectamos.
if len(y_train) == 0:
return np.zeros(h, dtype=float)
df = pd.DataFrame({"FECHA": dates_train, "y": y_train})
df["MONTH_NUM"] = pd.to_datetime(df["FECHA"]).dt.month
med = df.groupby("MONTH_NUM")["y"].median().to_dict()
last_date = pd.to_datetime(dates_train[-1]).to_period("M").to_timestamp()
future = pd.date_range(last_date + pd.offsets.MonthBegin(1), periods=h, freq="MS")
return np.array([med.get(d.month, float(y_train[-1])) for d in future], dtype=float)
# Croston/SBA/TSB (implementaciones directas sobre demanda intermitente)
def _croston_classic(y, h, alpha=0.1):
# Implementamos Croston clásico sobre valores no cero y tiempos entre demandas.
y = np.asarray(y, dtype=float)
n = len(y)
if n == 0:
return np.zeros(h, dtype=float)
# Extraemos demandas y intervalos
q = []
a = []
for v in y:
if v > 0:
q.append(v)
if len(q) == 0:
return np.zeros(h, dtype=float)
# Estimadores recursivos
z = 0.0
p = 0.0
k = 0
first = True
for v in y:
k += 1
if v > 0:
if first:
z = v
p = k
first = False
else:
z = z + alpha * (v - z)
p = p + alpha * (k - p)
k = 0
# Pronóstico Croston = z/p
f = (z / p) if p > 0 else 0.0
return np.repeat(f, h)
def _croston_sba(y, h, alpha=0.1):
# SBA corrige el sesgo multiplicando por (1 - alpha/2)
base = _croston_classic(y, h, alpha=alpha)
return base * (1.0 - alpha / 2.0)
def _tsb(y, h, alpha=0.2, beta=0.1):
# TSB modela probabilidad de demanda y tamaño por separado.
y = np.asarray(y, dtype=float)
n = len(y)
if n == 0:
return np.zeros(h, dtype=float)
demand = (y > 0).astype(int)
sizes = np.where(y > 0, y, 0.0)
p = demand[0]
z = sizes[0] if sizes[0] > 0 else (np.mean(sizes[sizes > 0]) if np.any(sizes > 0) else 0.0)
for t in range(1, n):
p = p + alpha * (demand[t] - p)
if y[t] > 0:
z = z + beta * (y[t] - z)
f = p * z
return np.repeat(float(f), h)
def _theta_predict(y_train, dates_train, h):
# Implementamos Theta usando statsmodels con period=12 para satisfacer el requisito del modelo.
if ThetaModel is None or len(y_train) == 0:
return np.repeat(float(y_train[-1]) if len(y_train) else 0.0, h)
try:
y = np.asarray(y_train, dtype=float)
# Forzamos period=12 para mensualidad; así no dependemos de la freq del índice
tm = ThetaModel(y, period=12)
res = tm.fit()
return np.asarray(res.forecast(h), dtype=float)
except Exception:
# Si fallara por cualquier razón, caemos a una baseline robusta
return _baseline_median_seasonal(y_train, dates_train, h, m=12)
def _ets_auto_predict(y_train, h, seasonal="add"):
# Probamos una configuración ETS básica si está disponible; si no, caemos a Naive1.
if ExponentialSmoothing is None or len(y_train) < 3:
return _baseline_naive1(y_train, h)
try:
model = ExponentialSmoothing(
np.asarray(y_train, dtype=float),
trend="add",
seasonal=seasonal, # "add" o "mul"
seasonal_periods=12,
damped_trend=True,
initialization_method="estimated"
).fit(optimized=True)
return np.asarray(model.forecast(h), dtype=float)
except Exception:
return _baseline_naive1(y_train, h)
def _holt_damped_predict(y_train, h):
# Implementamos Holt amortiguado vía ETS sin estacionalidad (si no, Naive1).
if ExponentialSmoothing is None or len(y_train) < 3:
return _baseline_naive1(y_train, h)
try:
model = ExponentialSmoothing(
np.asarray(y_train, dtype=float),
trend="add",
seasonal=None,
damped_trend=True,
initialization_method="estimated"
).fit(optimized=True)
return np.asarray(model.forecast(h), dtype=float)
except Exception:
return _baseline_naive1(y_train, h)
def _arima_auto_light(y_train, h):
# Hacemos un grid muy ligero sobre (p,d,q) pequeños; si falla, Naive1.
if SARIMAX is None or len(y_train) < 8:
return _baseline_naive1(y_train, h)
y = np.asarray(y_train, dtype=float)
best_aic = np.inf
best_forecast = None
for p in [0,1]:
for d in [0,1]:
for q in [0,1]:
try:
mod = SARIMAX(y, order=(p,d,q), seasonal_order=(0,0,0,0), trend="c", enforce_stationarity=False, enforce_invertibility=False)
res = mod.fit(disp=False)
if res.aic < best_aic:
best_aic = res.aic
best_forecast = res.forecast(h)
except Exception:
continue
if best_forecast is None:
return _baseline_naive1(y_train, h)
return np.asarray(best_forecast, dtype=float)
def _adida_mapa(y_train, h, low_freq="Q", low_models=("ETS_auto","Theta")):
# Implementamos un ADIDA/MAPA simple:
# 1) Agregamos la serie a baja frecuencia (trimestral).
# 2) Pronosticamos a baja frecuencia con ETS o Theta.
# 3) Desagregamos por perfil histórico proporcional.
y = np.asarray(y_train, dtype=float)
if len(y) < 6:
return _baseline_median_seasonal(y_train, dates_train=np.arange(len(y)), h=h, m=12)
# Agregación trimestral
pad = (3 - (len(y) % 3)) % 3
y_pad = np.hstack([y, np.zeros(pad)])
yQ = y_pad.reshape(-1, 3).sum(axis=1)
# Modelo a baja frecuencia
if "ETS_auto" in low_models and ExponentialSmoothing is not None and len(yQ) >= 3:
try:
mQ = ExponentialSmoothing(yQ, trend="add", seasonal=None, damped_trend=True).fit(optimized=True)
fq = np.asarray(mQ.forecast(int(np.ceil(h/3))), dtype=float)
except Exception:
fq = np.repeat(float(yQ[-1]) if len(yQ) else 0.0, int(np.ceil(h/3)))
elif "Theta" in low_models and ThetaModel is not None:
try:
tm = ThetaModel(yQ).fit()
fq = np.asarray(tm.forecast(int(np.ceil(h/3))), dtype=float)
except Exception:
fq = np.repeat(float(yQ[-1]) if len(yQ) else 0.0, int(np.ceil(h/3)))
else:
fq = np.repeat(float(yQ[-1]) if len(yQ) else 0.0, int(np.ceil(h/3)))
# Perfil histórico mensual proporcional dentro de cada trimestre
# Calculamos pesos medios por posición (mes1, mes2, mes3) en los trimestres históricos.
if len(y) >= 6:
blocks = y_pad.reshape(-1, 3)
totals = blocks.sum(axis=1, keepdims=True)
weights = np.divide(blocks, np.where(totals==0, 1.0, totals))
w = np.nan_to_num(weights.mean(axis=0))
if w.sum() == 0:
w = np.array([1/3, 1/3, 1/3], dtype=float)
else:
w = np.array([1/3, 1/3, 1/3], dtype=float)
# Desagregamos cada trimestre pronosticado en 3 meses con los pesos medios
f_months = []
for qv in fq:
f_months.extend(list(w * qv))
f_months = np.asarray(f_months, dtype=float)[:h]
return f_months
def fit_predict(model_spec: dict, y_train, dates_train, horizon, freq="MS", exog=None) -> np.ndarray:
# Enrutamos por familia y devolvemos un np.ndarray de tamaño h.
fam = model_spec.get("family", "").lower()
name = model_spec.get("name", "").lower()
params = model_spec.get("params", {}) or {}
if fam == "baseline":
t = (params.get("type") or "").lower()
if t == "naive1":
return _baseline_naive1(y_train, horizon)
if t == "snaive12":
return _baseline_snaive12(y_train, dates_train, horizon)
if t == "median_seasonal":
return _baseline_median_seasonal(y_train, dates_train, horizon, m=params.get("m", 12))
if t == "perfil_escaso_uniforme":
return np.repeat(np.mean(np.asarray(y_train, dtype=float)), horizon) if len(y_train) else np.zeros(horizon)
if t == "cero":
return np.zeros(horizon, dtype=float)
return _baseline_naive1(y_train, horizon)
if fam == "theta":
return _theta_predict(y_train, dates_train, horizon)
if fam == "ets":
comps = params.get("components", ["A/A", "A/M"])
# Probamos una variante aditiva por simplicidad
return _ets_auto_predict(y_train, horizon, seasonal="add")
if fam == "holt":
return _holt_damped_predict(y_train, horizon)
if fam == "arima":
return _arima_auto_light(y_train, horizon)
if fam == "croston":
variant = (params.get("variant") or "classic").lower()
alpha = float(params.get("alpha", 0.1))
if variant == "sba":
return _croston_sba(y_train, horizon, alpha=alpha)
return _croston_classic(y_train, horizon, alpha=alpha)
if fam == "tsb":
alpha = float(params.get("alpha", 0.2))
beta = float(params.get("beta", 0.1))
return _tsb(y_train, horizon, alpha=alpha, beta=beta)
if fam == "adida_mapa":
return _adida_mapa(y_train, horizon,
low_freq=params.get("agg_level", "Q"),
low_models=tuple(params.get("model_low_freq", ["ETS_auto","Theta"])))
# Si no reconocemos la familia, devolvemos Naive1 como red de seguridad.
return _baseline_naive1(y_train, horizon)
Bloque 5 - Carga de artefactos del Paso 2–3 y panel de trabajo¶
# =========================
# Bloque 5) Carga de artefactos del Paso 2–3 y panel de trabajo
# =========================
# Cargamos segmentación, listas y menú de modelos. Validamos columnas mínimas.
assert os.path.exists(PATH_SEG_ENR), f"Falta {PATH_SEG_ENR}"
assert os.path.exists(PATH_PAIRS_INT), f"Falta {PATH_PAIRS_INT}"
assert os.path.exists(PATH_PAIRS_GEN), f"Falta {PATH_PAIRS_GEN}"
assert os.path.exists(PATH_CFG_JSON), f"Falta {PATH_CFG_JSON}"
assert "train_full" in globals(), "Falta train_full del Paso 0."
assert "test_full" in globals(), "Falta test_full del Paso 0."
seg_enr = pd.read_csv(PATH_SEG_ENR, sep=CSV_SEP)
pairs_int = pd.read_csv(PATH_PAIRS_INT, sep=CSV_SEP)
pairs_gen = pd.read_csv(PATH_PAIRS_GEN, sep=CSV_SEP)
with open(PATH_CFG_JSON, "r", encoding="utf-8") as f:
step3_cfg = json.load(f)
flag_col = step3_cfg.get("flags", {}).get("all_zero_flag_column", "flag_all_zero_train")
# Aseguramos dataframes canónicos
train_df = _ensure_dtindex(train_full, DATECOL)
test_df = _ensure_dtindex(test_full, DATECOL)
all_df = _concat_train_test(train_df, test_df)
# Construimos el panel de pares y rutas
pairs_int["route"] = "INTERMITENTE"
pairs_gen["route"] = "GENERAL"
panel_pairs = pd.concat([pairs_int, pairs_gen], ignore_index=True)
panel_pairs = panel_pairs.merge(seg_enr[PAIR_COLS + [flag_col, "segment"]].drop_duplicates(),
on=PAIR_COLS, how="left")
panel_pairs["route"] = panel_pairs["route"].fillna(panel_pairs["segment"]) # por si faltara
# Resumen poblacional para log
n_total = panel_pairs[PAIR_COLS].drop_duplicates().shape[0]
n_int = (panel_pairs["route"] == "INTERMITENTE").sum()
n_gen = (panel_pairs["route"] == "GENERAL").sum()
log4(f"Población Paso 4 — total={n_total} | INTERMITENTE={n_int} | GENERAL={n_gen}")
Bloque 6 - Construcción de menú por ruta¶
# =========================
# Bloque 6) Construcción de menú por ruta
# =========================
menu_routes = step3_cfg.get("routes", {})
def _get_models_for_route(route: str):
# Obtenemos candidatos + baselines para una ruta dada.
cfg = menu_routes.get(route.upper(), {})
candidatos = cfg.get("candidatos", [])
baselines = cfg.get("baselines", [])
return list(candidatos) + list(baselines)
Bloque 7 - Bucle principal — Rolling origin¶
# =========================
# Bloque 7) Bucle principal — Rolling origin
# =========================
# Generamos la tabla raw con filas (pair, route, model, origin, h, métricas).
raw_rows = []
# Preparamos índice por pareja para extraer series más rápido
all_df = all_df.sort_values([*PAIR_COLS, DATECOL])
grouped = all_df.groupby(PAIR_COLS, sort=False)
for _, row in panel_pairs.iterrows():
bid, ctype, route = row["ID_BUILDING"], row["FM_COST_TYPE"], str(row["route"]).upper()
is_all_zero = int(row.get(flag_col, 0)) == 1
# Extraemos serie completa 2021–2024 de la pareja
try:
sub = grouped.get_group((bid, ctype)).sort_values(DATECOL)
except KeyError:
# Si no encontramos la pareja en el agregado, continuamos
continue
y_all = sub[VALCOL].astype(float).values
d_all = sub[DATECOL].values
# Si es todo-cero (bandera), registramos baseline_cero y pasamos a la siguiente pareja.
if is_all_zero:
for origin in ORIGINS:
for h in H_LIST:
raw_rows.append({
"ID_BUILDING": bid, "FM_COST_TYPE": ctype,
"route": route, "model_name": "baseline_cero",
"origin": origin, "horizon": h, "n_obs": 0,
"MAE": 0.0, "WAPE": 0.0, "SMAPE": 0.0, "MASE1": 0.0, "MASE12": 0.0
})
log4(f"[{bid}|{ctype}] flag_all_zero_train=1 -> baseline_cero registrado.")
continue
# Preparamos lista de modelos para la ruta
models = _get_models_for_route(route)
# Iteramos orígenes y horizontes
for origin in ORIGINS:
origin_ts = pd.to_datetime(origin).to_period("M").to_timestamp()
# Definimos ventana de train (<= origin-1M) y ventana de evaluación (origin..origin+h-1)
# Calculamos índices
mask_train = d_all <= (origin_ts - pd.offsets.MonthBegin(1))
if not np.any(mask_train):
continue # si no hay histórico, saltamos origen
y_tr = y_all[mask_train]
d_tr = d_all[mask_train]
for h in H_LIST:
# Fechas futuras a evaluar
future = pd.date_range(origin_ts, periods=h, freq=FREQ)
# Verdad-terreno: tomamos de d_all / y_all si existen
mask_te = np.isin(d_all, future)
if mask_te.sum() == 0:
continue # no hay cobertura en este origen-h, lo ignoramos
y_true = y_all[mask_te]
# Puede ocurrir que falten algunos meses en la intersección; tomamos la longitud efectiva
eff_h = len(y_true)
f_dates = future[:eff_h]
# Ajustamos métricas usando escalas calculadas sobre y_tr
for m in models:
try:
y_pred_full = fit_predict(m, y_tr, d_tr, horizon=len(future), freq=FREQ)
except Exception as e:
# Si el modelo falla, registramos NaN y continuamos
log4(f"[{bid}|{ctype}] Error en {m.get('name')} ({route}) @ {origin}, h={h}: {e}")
continue
y_pred = np.asarray(y_pred_full, dtype=float)[:eff_h]
metrics = _eval_series(y_true, y_pred, y_train_for_scales=y_tr)
raw_rows.append({
"ID_BUILDING": bid, "FM_COST_TYPE": ctype,
"route": route, "model_name": m.get("name"),
"origin": origin, "horizon": h, "n_obs": eff_h,
**metrics
})
# Construimos DataFrame raw y persistimos
if len(raw_rows) == 0:
log4("No generamos filas en rolling_metrics_raw (revisar cobertura/orígenes).")
rolling_raw = pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","route","model_name","origin","horizon","n_obs","MAE","WAPE","SMAPE","MASE1","MASE12"])
else:
rolling_raw = pd.DataFrame(raw_rows)
rolling_raw.to_csv(PATH_RAW, sep=CSV_SEP, index=False)
log4(f"Guardado RAW: {PATH_RAW} con {len(rolling_raw)} filas")
Bloque 8 - Agregación por serie y modelo¶
# =========================
# Bloque 8) Agregación por serie y modelo
# =========================
if rolling_raw.empty:
rolling_agg = pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","route","model_name","n_origins","MAE_mean","WAPE_mean","SMAPE_mean","MASE1_mean","MASE12_mean"])
else:
rolling_agg = (
rolling_raw
.groupby(["ID_BUILDING","FM_COST_TYPE","route","model_name"], as_index=False)
.agg(
n_origins=("origin", "nunique"),
MAE_mean=("MAE","mean"),
WAPE_mean=("WAPE","mean"),
SMAPE_mean=("SMAPE","mean"),
MASE1_mean=("MASE1","mean"),
MASE12_mean=("MASE12","mean")
)
)
rolling_agg.to_csv(PATH_AGG, sep=CSV_SEP, index=False)
log4(f"Guardado AGG: {PATH_AGG} con {len(rolling_agg)} filas")
Bloque 9 - Selección top-2 y comparación contra SNaive12 (2024)¶
# =========================
# Bloque 9) Selección top-2 y comparación contra SNaive12 (2024)
# =========================
# Leemos desempeño de SNaive12 en 2024 desde Paso 1
if os.path.exists(PATH_BASELINES_2024):
baselines2024 = pd.read_csv(PATH_BASELINES_2024, sep=CSV_SEP)
snaive24 = baselines2024[baselines2024["baseline"] == "SNaive12"][PAIR_COLS + ["MASE12"]].rename(columns={"MASE12":"MASE12_SNaive12_2024"})
else:
snaive24 = pd.DataFrame(columns=PAIR_COLS + ["MASE12_SNaive12_2024"])
log4(f"AVISO: no encontramos {PATH_BASELINES_2024}. La comparación contra SNaive12_2024 quedará vacía.")
# Seleccionamos top-2 por pareja según MASE12_mean (desempate WAPE_mean)
def _select_top2(df_pair):
df_pair = df_pair.sort_values(["MASE12_mean","WAPE_mean"], ascending=[True, True])
top = df_pair.head(2).reset_index(drop=True)
out = {
"modelo_top1": top.loc[0, "model_name"] if len(top) > 0 else None,
"MASE12_top1": float(top.loc[0, "MASE12_mean"]) if len(top) > 0 else np.nan,
"modelo_top2": top.loc[1, "model_name"] if len(top) > 1 else None,
"MASE12_top2": float(top.loc[1, "MASE12_mean"]) if len(top) > 1 else np.nan,
"route": df_pair["route"].iloc[0] if len(df_pair) else None
}
return pd.Series(out)
if rolling_agg.empty:
sel = pd.DataFrame(columns=PAIR_COLS + ["route","modelo_top1","modelo_top2","MASE12_top1","MASE12_top2"])
else:
sel = rolling_agg.groupby(PAIR_COLS, as_index=False).apply(_select_top2)
if isinstance(sel.columns, pd.MultiIndex):
sel.columns = sel.columns.get_level_values(-1)
sel = sel.reset_index(drop=True)
# Comparamos contra SNaive12_2024
sel = sel.merge(snaive24, on=PAIR_COLS, how="left")
sel["delta_MASE12_vs_SNaive12"] = sel["MASE12_top1"] - sel["MASE12_SNaive12_2024"]
sel["winner_beats_snaive12"] = (sel["delta_MASE12_vs_SNaive12"] < 0).astype(int)
# Regla adicional: si no hay mejora vs SNaive12_2024, fijamos modelo_top1=SNaive12 y dejamos top2 como estaba
mask_no_improve = (~sel["MASE12_SNaive12_2024"].isna()) & (sel["winner_beats_snaive12"] == 0)
sel.loc[mask_no_improve, "modelo_top1"] = "SNaive12"
# Añadimos los casos flag_all_zero_train con baseline_cero si no aparecieran por ausencia de raw
zeros_df = panel_pairs.loc[panel_pairs[flag_col] == 1, PAIR_COLS].drop_duplicates()
if not zeros_df.empty:
zeros_df = zeros_df.assign(route="INTERMITENTE",
modelo_top1="baseline_cero",
modelo_top2=None,
MASE12_top1=0.0,
MASE12_top2=np.nan,
MASE12_SNaive12_2024=np.nan,
delta_MASE12_vs_SNaive12=np.nan,
winner_beats_snaive12=1)
sel = pd.concat([sel, zeros_df], ignore_index=True).drop_duplicates(subset=PAIR_COLS, keep="first")
# Persistimos selección
sel = sel[PAIR_COLS + ["route","modelo_top1","modelo_top2","MASE12_top1","MASE12_top2","winner_beats_snaive12","delta_MASE12_vs_SNaive12"]]
sel.to_csv(PATH_SEL, sep=CSV_SEP, index=False)
log4(f"Guardado SELECCIÓN: {PATH_SEL} con {len(sel)} parejas")
Bloque 10 - Punto de control D (diagnóstico)¶
# =========================
# Bloque 10) Punto de control D (diagnóstico)
# =========================
if len(sel) > 0 and "winner_beats_snaive12" in sel.columns:
pct_mejora = float(sel["winner_beats_snaive12"].mean()) if len(sel) else 0.0
log4(f"Punto D — % de series donde top1 supera a SNaive12_2024: {pct_mejora:.2%}")
# Si el porcentaje es bajo, registramos recomendación operativa
if pct_mejora < 0.30:
log4("Recomendación: reducir candidatos a Theta/ETS y/o activar tratamiento de outliers (mod_3) para las peores series y revalidar.")
elif pct_mejora < 0.40:
log4("Aviso: mejora moderada; considerar reducir menú a modelos más estables y revisar outliers selectivamente.")
# Listado de series problemáticas por umbrales del JSON (si existen)
thr = step3_cfg.get("metrics", {}).get("thresholds_fail", {"MASE12": 1.2, "SMAPE": 25.0})
if not rolling_agg.empty:
# Unimos top1 con su MASE12_mean y SMAPE_mean
top1_perf = (rolling_agg.merge(sel[PAIR_COLS + ["modelo_top1"]], left_on=PAIR_COLS + ["model_name"], right_on=PAIR_COLS + ["modelo_top1"], how="inner"))
prob = top1_perf[(top1_perf["MASE12_mean"] > float(thr.get("MASE12", 1.2))) | (top1_perf["SMAPE_mean"] > float(thr.get("SMAPE", 25.0)))]
prob = prob.sort_values(["MASE12_mean","SMAPE_mean"], ascending=[False, False]).head(20)
if not prob.empty:
log4(f"Series por encima de umbrales (top 20): {len(prob)} casos")
# Imprimimos algunas para inspección rápida
display(prob[PAIR_COLS + ["model_name","MASE12_mean","SMAPE_mean","WAPE_mean"]].head(10))
else:
log4("Punto D — No hay selección o columna de mejora, revisar ejecución del rolling.")
[2025-09-21T22:31:32] Población Paso 4 — total=2429 | INTERMITENTE=1397 | GENERAL=1032 [2025-09-21T22:31:32] [2|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:33] [9|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:33] [18|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:34] [59|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:34] [62|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:34] [74|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:37] [127|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:37] [129|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:41] [137|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:43] [146|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:43] [149|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:46] [168|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:47] [171|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:47] [171|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:49] [179|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:50] [199|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:57] [215|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:31:59] [224|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:00] [229|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:01] [231|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:03] [239|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:03] [239|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:06] [260|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:07] [265|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:12] [345|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:14] [379|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:14] [594|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:14] [617|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:16] [713|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:18] [935|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:19] [1007|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:22] [1060|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:22] [1065|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:29] [1111|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:33] [1306|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:34] [1462|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:36] [1000026|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:42] [1000270|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:43] [1000345|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:45] [1000409|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:45] [1000413|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:45] [1000414|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:46] [1000431|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:47] [1000437|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:47] [1000438|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:48] [1000449|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:48] [1000450|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:48] [1000451|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:49] [1000484|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:50] [1000496|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:50] [1000496|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:52] [1000532|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:52] [1000532|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:56] [1000558|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:32:58] [1000601|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:01] [1000666|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:01] [1000667|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:01] [1000667|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:01] [1000667|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:02] [1000733|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000758|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000761|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000762|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000763|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000764|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:03] [1000765|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:04] [1000766|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:06] [1000817|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:10] [1000859|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:12] [1000925|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:13] [1001055|Mtto. Correctivo] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:13] [1001077|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:15] [1001113|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:15] [1001113|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:17] [1001119|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:19] [1001122|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:20] [1001126|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:20] [1001127|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:25] [1001133|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:26] [1001134|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:26] [1001135|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:27] [1001140|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:28] [1001141|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:28] [1001143|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:30] [1001151|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:31] [1001154|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:32] [1001156|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:33] [1001159|Servicios Extra] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:33] [1001160|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:38] [1001173|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:38] [1001173|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:42] [1001205|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:42] [1001206|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:42] [1001207|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:42] [1001209|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:42] [1001213|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:43] [1001252|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:44] [1001253|Eficiencia Energética] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:45] [1001265|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:49] [1001365|Obras] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:54] [1001388|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:56] [1001407|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:58] [1001471|Mtto. Correctivo] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T22:33:58] [1001476|Licencias] flag_all_zero_train=1 -> baseline_cero registrado. [2025-09-21T23:08:39] Guardado RAW: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/rolling_metrics_raw.csv con 45258 filas [2025-09-21T23:08:39] Guardado AGG: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/rolling_metrics_agg.csv con 15086 filas [2025-09-21T23:08:43] Guardado SELECCIÓN: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/seleccion_modelos_cv.csv con 2429 parejas [2025-09-21T23:08:43] Punto D — % de series donde top1 supera a SNaive12_2024: 69.78% [2025-09-21T23:08:43] Series por encima de umbrales (top 20): 20 casos
ID_BUILDING | FM_COST_TYPE | model_name | MASE12_mean | SMAPE_mean | WAPE_mean | |
---|---|---|---|---|---|---|
235 | 162 | Mtto. Contratos | SNaive12 | 583.692762 | 66.666667 | 3.333333e-01 |
1794 | 1000817 | Servicios Extra | SNaive12 | 403.902000 | 66.666667 | 3.333333e-01 |
1592 | 1000595 | Servicios Extra | SNaive12 | 346.189744 | 66.666667 | 3.333333e-01 |
1161 | 1000026 | Servicios Ctto. | SNaive12 | 290.691029 | 133.333333 | 8.092667e+09 |
727 | 1059 | Servicios Extra | SNaive12 | 214.160283 | 133.333333 | 6.544667e+09 |
2078 | 1001155 | Servicios Ctto. | PerfilEscaso | 189.624288 | 200.000000 | 1.281188e+09 |
2085 | 1001156 | Servicios Ctto. | PerfilEscaso | 189.624288 | 200.000000 | 1.281188e+09 |
2072 | 1001154 | Servicios Ctto. | PerfilEscaso | 189.624288 | 200.000000 | 1.341785e+09 |
1806 | 1000859 | Mtto. Contratos | SNaive12 | 186.554212 | 133.333333 | 2.972667e+09 |
2002 | 1001135 | Servicios Ctto. | PerfilEscaso | 179.189020 | 200.000000 | 1.333313e+09 |
Interpretación Paso 4.¶
Cobertura de población correcta. Total 2.429 parejas; INTERMITENTE=1.397 (≈57,5%) y GENERAL=1.032 (≈42,5%). Cuadra con Paso 2/3 y confirma que la orquestación por rutas está funcionando.
Series “todo-cero” bien tratadas. Se registran decenas de mensajes flag_all_zero_train=1 -> baseline_cero registrado. Eso significa que, para esas parejas, dejamos pronóstico estructural = 0 y no gastamos cómputo en modelos complejos, tal y como definimos en la gobernanza del Paso 3.
Persistencias creadas sin huecos:
rolling_metrics_raw.csv → 45.258 filas.
rolling_metrics_agg.csv → 15.086 filas.
seleccion_modelos_cv.csv → 2.429 parejas (una por cada serie modelable o tratada como cero estructural). Perfecto: coincide con el universo esperado.
Punto D (diagnóstico clave): el 69,78% de las series donde aplicamos la selección tienen winner_beats_snaive12 = 1. Esto es muy buen resultado: el top-1 gana a SNaive12 en ~7 de cada 10 parejas, holgando el umbral de 30-40% que nos habíamos dado como mínimo aceptable.
Qué significan los “top 20” por encima de umbrales
En la tabla que se imprime, vemos casos con:
SMAPE de 133-200% y MASE12_mean extremadamente altos (p. ej., 583; 404; 346…).
WAPE con valores astronómicos (10⁹).
Esto no implica un bug en el cómputo, sino un efecto esperado cuando:
La verdad-terreno del horizonte es casi nula (sum(|y|) muy pequeña) → el denominador del WAPE “explota”.
Intermitencia alta con muchos ceros → SMAPE se dispara (si y_true≈0 y hay predicción >0, la fracción queda muy penalizada).
La escala de MASE12 en train es muy pequeña (serie plana o casi-cero) → cualquier error en test da MASE12 >> 1.
Además, varios de esos “peores” casos salen con SNaive12 o PerfilEscaso como modelo, lo que también es coherente: son series donde, por su naturaleza y/o por datos muy pequeños, las métricas relativas dejan de ser informativas.
Conclusiones:
Hemos cumplido exactamente el objetivo del Paso 4: validación rolling corta 2023, métricas por origen, agregado por serie/modelo, top-2 por serie y comparación rigurosa contra SNaive12_2024. La selección se ha persistido para toda la población (incluidas las “cero estructural” con su tratamiento explícito).
El % de mejora vs SNaive12 es notable (≈70%): justifica mantener el menú de candidatos actual (GENERAL: Theta/ETS/ARIMA/Holt amortiguado; INTERMITENTE: Croston/SBA/TSB/ADIDA-MAPA), sin necesidad de recortar aún.
Los outliers de métrica se explican por denominadores casi nulos y altísima intermitencia. Las métricas relativas hacen su trabajo (señalan riesgo), pero no deben leerse en bruto sin contexto de volumen.
Paso 5 - Ensembles y predicción 2024 (12M) por serie¶
El Paso 5 convierte el aprendizaje de la validación (Paso 4) en pronósticos productivos y estables, con cobertura total, reglas claras, y salida estándar para que finanzas y operaciones la usen sin fricción
Convertir la selección en predicción real. Tras el Paso 4 ya sabemos el top-2 de modelos por serie (según CV). El Paso 5 toma esos candidatos, los re-entrena con todo el histórico 2021–2023 y genera la predicción completa de 2024.
Aumentar la estabilidad con ensembles. Combinamos (50/50 o con pesos por 1/MASE12) los dos mejores modelos de cada serie para reducir varianza y mitigar el riesgo de un “mal mes” de un único modelo. Es un movimiento clásico de bias-variance tradeoff aplicado a forecasting operativo.
Garantizar cobertura 100% con reglas de fallback.
SOLO TEST (sin histórico): SNaive(12).
Cero estructural (36 meses a 0): baseline 0. Con esto, todas las parejas acaban con ŷ_2024 (o equivalente), sin huecos.
Aplicar tratamiento específico a intermitentes. Priorizamos TSB (y, si interesa, ADIDA/MAPA) porque modelan bien demanda esporádica. Si no superan SNaive(12) en CV, volvemos a SNaive(12). Así evitamos sobre-modelar donde no aporta.
Unificar salida canónica para explotación. Entregamos un único CSV (preds_por_serie_2024.csv) con: claves, meses 2024, ŷ de cada modelo, ŷ combinada, fallback_flag, ruta y regla usada. Esta tabla es lista para reporting, agregación por portfolio y presupuestación.
Trazabilidad operativa y auditoría. El log del paso documenta qué regla se aplicó en cada serie, si hubo fallback, y qué porcentaje del total requirió medidas de seguridad. Facilita auditar decisiones y repetir el flujo.
Monitorizar “cero → actividad” (gobernanza). Para series tratadas como cero estructural, si 2024 muestra gasto, generamos alerta para revisión. Así capturamos cambios reales del negocio (nuevos contratos, reaperturas, etc.).
Control de calidad final (Punto E). Verificamos:
12 meses por serie (enero–diciembre 2024).
Cobertura = nº de parejas del panel.
% de fallback global y por ruta. Es el “OK técnico” previo a usar las predicciones.
Robustez numérica y limpieza. Evitamos inestabilidades (SMAPE/WAPE con denominadores pequeños), clipeamos a ≥0 y caemos a SNaive(12) si un modelo falla. El objetivo es salida fiable antes que sofisticación frágil.
Qué habilita para negocio. Con ŷ_2024 por serie:
Presupuesto mensual por edificio/partida y agregado de portfolio.
Comparativa y “what-if” frente a baselines.
Detección temprana de desviaciones y preparación de compras/contratos.
Bloque 1 — Parámetros y logging¶
# ============================================================
# ESTRATEGIA 3 — PASO 5
# ENSEMBLES Y PREDICCIÓN 2024 (12M) POR SERIE
# ============================================================
# En este paso generamos ŷ_2024 por pareja (ID_BUILDING, FM_COST_TYPE)
# combinando top-2 modelos (50/50 o ponderado por 1/MASE12_mean),
# con fallbacks (SNaive12, baseline_cero) y trazabilidad en logs.
# ============================================================
# -----------------------------
# Bloque 1 — Parámetros y logging
# -----------------------------
# Fijamos semilla global para reproducibilidad de cualquier aleatoriedad interna.
np.random.seed(7)
# Asumimos que estas variables existen desde pasos previos; si no, definimos defaults seguros.
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
PAIR_COLS = globals().get("PAIR_COLS", ["ID_BUILDING","FM_COST_TYPE"])
DATECOL = globals().get("DATECOL", "FECHA")
VALUE_TRAINTEST = globals().get("VALUE_TRAINTEST", "cost_float_mod")
# Rutas de trabajo
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_CONFIG = os.path.join(ruta_base_3, "CONFIG")
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
os.makedirs(RUTA_RESULTADOS, exist_ok=True)
os.makedirs(RUTA_CONFIG, exist_ok=True)
# Log del paso
LOG_PATH_STEP5 = os.path.join(RUTA_LOGS, "estrategia3_step5.log")
def log5(msg: str):
# Dejamos traza en archivo y consola para auditoría
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP5, "a", encoding="utf-8") as f:
f.write(line + "\n")
# Verificamos presencia de dataframes canónicos del Paso 0 (train_full/test_full)
assert "train_full" in globals(), "Falta train_full (generado en Paso 0)."
assert "test_full" in globals(), "Falta test_full (generado en Paso 0)."
# Normalizamos fechas a mensual (MS)
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
train_full = _ensure_ms(train_full, DATECOL)
test_full = _ensure_ms(test_full, DATECOL)
# Definimos rango futuro 2024
FREQ = "MS"
H = 12
FUTURE_2024 = pd.date_range("2024-01-01", periods=H, freq=FREQ)
log5("Iniciamos Paso 5 — Ensembles y predicción 2024.")
Bloque 2 — Carga artefactos y panel de trabajo¶
# -----------------------------------------
# Bloque 2 — Carga artefactos y panel de trabajo
# -----------------------------------------
# Artefactos esperados
PATH_SEG_ENR = os.path.join(RUTA_METRICAS, "segmentation_intermitencia_step3.csv")
PATH_PAIRS_INT = os.path.join(RUTA_METRICAS, "pairs_intermittentes.csv")
PATH_PAIRS_GEN = os.path.join(RUTA_METRICAS, "pairs_generales.csv")
PATH_CFG_JSON = os.path.join(RUTA_CONFIG, "step3_menu_modelos_config.json")
PATH_ROLL_AGG = os.path.join(RUTA_METRICAS, "rolling_metrics_agg.csv")
PATH_SEL_CV = os.path.join(RUTA_METRICAS, "seleccion_modelos_cv.csv")
PATH_BASELINES = os.path.join(RUTA_METRICAS, "metrics_baselines_cv.csv")
PATH_SOLOTEST = os.path.join(RUTA_METRICAS, "pairs_solo_test.csv") # del Paso 0
# Cargamos artefactos mínimos
seg_enr = pd.read_csv(PATH_SEG_ENR, sep=CSV_SEP)
pairs_int = pd.read_csv(PATH_PAIRS_INT, sep=CSV_SEP)
pairs_gen = pd.read_csv(PATH_PAIRS_GEN, sep=CSV_SEP)
sel_cv = pd.read_csv(PATH_SEL_CV, sep=CSV_SEP)
# Cargamos rolling agg y baselines si existen (para ponderación por 1/MASE12 y comparación TSB vs SNaive12)
roll_agg = pd.read_csv(PATH_ROLL_AGG, sep=CSV_SEP) if os.path.exists(PATH_ROLL_AGG) else pd.DataFrame()
df_base = pd.read_csv(PATH_BASELINES, sep=CSV_SEP) if os.path.exists(PATH_BASELINES) else pd.DataFrame()
pairs_solo_test = pd.read_csv(PATH_SOLOTEST, sep=CSV_SEP) if os.path.exists(PATH_SOLOTEST) else pd.DataFrame(columns=PAIR_COLS)
# Panel de pares con route y bandera all_zero
flag_col = "flag_all_zero_train"
pairs_int["route"] = "INTERMITENTE"
pairs_gen["route"] = "GENERAL"
panel_pairs = pd.concat([pairs_int, pairs_gen], ignore_index=True)
panel_pairs = panel_pairs.merge(
seg_enr[PAIR_COLS + ["segment", flag_col]].drop_duplicates(),
on=PAIR_COLS, how="left"
)
panel_pairs["route"] = panel_pairs["route"].fillna(panel_pairs["segment"]).fillna("GENERAL")
# Añadimos top-1 y top-2 desde selección
panel_pairs = panel_pairs.merge(
sel_cv[PAIR_COLS + ["modelo_top1", "modelo_top2"]],
on=PAIR_COLS, how="left"
)
# Conteos de referencia
n_total = panel_pairs[PAIR_COLS].drop_duplicates().shape[0]
n_int = (panel_pairs["route"] == "INTERMITENTE").sum()
n_gen = (panel_pairs["route"] == "GENERAL").sum()
n_zero = int(panel_pairs[flag_col].fillna(0).astype(int).sum())
log5(f"Población Paso 5 — total={n_total} | INTERMITENTE={n_int} | GENERAL={n_gen} | all_zero={n_zero}")
Bloque 3 — Wrappers de predicción (reutilizamos Paso 4)¶
# ------------------------------------------------
# Bloque 3 — Wrappers de predicción (reutilizamos Paso 4)
# ------------------------------------------------
# Baselines y modelos
def _baseline_naive1(y_train, h):
# Repetimos el último valor observado
if len(y_train) == 0:
return np.zeros(h, dtype=float)
return np.repeat(float(y_train[-1]), h)
def _baseline_snaive12(y_train, dates_train, h):
# Usamos el valor del mismo mes del año anterior si existe; si no, caemos al último valor
if len(y_train) == 0:
return np.zeros(h, dtype=float)
df = pd.DataFrame({"FECHA": pd.to_datetime(dates_train), "y": y_train})
df["MONTH_NUM"] = df["FECHA"].dt.month
df["YEAR"] = df["FECHA"].dt.year
last_year = df["YEAR"].max()
map_last_year = df[df["YEAR"] == last_year].set_index("MONTH_NUM")["y"].to_dict()
last_date = pd.to_datetime(dates_train[-1]).to_period("M").to_timestamp()
future = pd.date_range(last_date + pd.offsets.MonthBegin(1), periods=h, freq="MS")
pred = []
for d in future:
pred.append(map_last_year.get(d.month, float(y_train[-1])))
return np.asarray(pred, dtype=float)
def _baseline_median_seasonal(y_train, dates_train, h, m=12):
# Mediana por mes sobre el train; fallback al último valor
if len(y_train) == 0:
return np.zeros(h, dtype=float)
df = pd.DataFrame({"FECHA": pd.to_datetime(dates_train), "y": y_train})
df["MONTH_NUM"] = df["FECHA"].dt.month
med = df.groupby("MONTH_NUM")["y"].median().to_dict()
last_date = pd.to_datetime(dates_train[-1]).to_period("M").to_timestamp()
future = pd.date_range(last_date + pd.offsets.MonthBegin(1), periods=h, freq="MS")
return np.array([med.get(d.month, float(y_train[-1])) for d in future], dtype=float)
def _theta_predict(y_train, dates_train, h):
# Theta con period=12; si falla o no está disponible, caemos a mediana estacional
if len(y_train) == 0:
return np.zeros(h, dtype=float)
if ThetaModel is None:
return _baseline_median_seasonal(y_train, dates_train, h, m=12)
try:
tm = ThetaModel(np.asarray(y_train, dtype=float), period=12).fit()
return np.asarray(tm.forecast(h), dtype=float)
except Exception:
return _baseline_median_seasonal(y_train, dates_train, h, m=12)
def _ets_auto_predict(y_train, h):
# ETS aditivo amortiguado; si no, Naive1
if len(y_train) < 3 or ExponentialSmoothing is None:
return _baseline_naive1(y_train, h)
try:
model = ExponentialSmoothing(
np.asarray(y_train, dtype=float),
trend="add", seasonal="add", seasonal_periods=12,
damped_trend=True, initialization_method="estimated"
).fit(optimized=True)
return np.asarray(model.forecast(h), dtype=float)
except Exception:
return _baseline_naive1(y_train, h)
def _holt_damped_predict(y_train, h):
# Holt amortiguado sin estacionalidad
if len(y_train) < 3 or ExponentialSmoothing is None:
return _baseline_naive1(y_train, h)
try:
model = ExponentialSmoothing(
np.asarray(y_train, dtype=float),
trend="add", seasonal=None,
damped_trend=True, initialization_method="estimated"
).fit(optimized=True)
return np.asarray(model.forecast(h), dtype=float)
except Exception:
return _baseline_naive1(y_train, h)
def _arima_auto_light(y_train, h):
# Grid ligero ARIMA; si falla, Naive1
if SARIMAX is None or len(y_train) < 8:
return _baseline_naive1(y_train, h)
y = np.asarray(y_train, dtype=float)
best_aic = np.inf
best_forecast = None
for p in [0,1]:
for d in [0,1]:
for q in [0,1]:
try:
mod = SARIMAX(y, order=(p,d,q), seasonal_order=(0,0,0,0),
trend="c", enforce_stationarity=False, enforce_invertibility=False)
res = mod.fit(disp=False)
if res.aic < best_aic:
best_aic = res.aic
best_forecast = res.forecast(h)
except Exception:
continue
if best_forecast is None:
return _baseline_naive1(y_train, h)
return np.asarray(best_forecast, dtype=float)
# Modelos intermitentes
def _croston_classic(y, h, alpha=0.1):
y = np.asarray(y, dtype=float)
n = len(y)
if n == 0:
return np.zeros(h, dtype=float)
z, p, k = 0.0, 0.0, 0
first = True
for v in y:
k += 1
if v > 0:
if first:
z, p, first = v, k, False
else:
z = z + alpha * (v - z)
p = p + alpha * (k - p)
k = 0
f = (z / p) if p > 0 else 0.0
return np.repeat(f, h)
def _croston_sba(y, h, alpha=0.1):
base = _croston_classic(y, h, alpha=alpha)
return base * (1.0 - alpha/2.0)
def _tsb(y, h, alpha=0.2, beta=0.1):
y = np.asarray(y, dtype=float)
if len(y) == 0:
return np.zeros(h, dtype=float)
demand = (y > 0).astype(int)
sizes = np.where(y > 0, y, 0.0)
p = demand[0]
z = sizes[0] if sizes[0] > 0 else (np.mean(sizes[sizes > 0]) if np.any(sizes > 0) else 0.0)
for t in range(1, len(y)):
p = p + alpha * (demand[t] - p)
if y[t] > 0:
z = z + beta * (y[t] - z)
f = p * z
return np.repeat(float(f), h)
def _adida_mapa(y_train, h):
# ADIDA/MAPA simple: agregación trimestral + modelo LF + desagregación por perfil histórico
y = np.asarray(y_train, dtype=float)
if len(y) < 6:
return _baseline_median_seasonal(y_train, np.arange(len(y)), h, m=12)
pad = (3 - (len(y) % 3)) % 3
y_pad = np.hstack([y, np.zeros(pad)])
yQ = y_pad.reshape(-1, 3).sum(axis=1)
# Modelo LF (preferimos ETS si hay suficientes puntos)
if ExponentialSmoothing is not None and len(yQ) >= 3:
try:
mQ = ExponentialSmoothing(yQ, trend="add", seasonal=None, damped_trend=True).fit(optimized=True)
fq = np.asarray(mQ.forecast(int(np.ceil(h/3))), dtype=float)
except Exception:
fq = np.repeat(float(yQ[-1]) if len(yQ) else 0.0, int(np.ceil(h/3)))
else:
try:
tm = ThetaModel(yQ).fit()
fq = np.asarray(tm.forecast(int(np.ceil(h/3))), dtype=float)
except Exception:
fq = np.repeat(float(yQ[-1]) if len(yQ) else 0.0, int(np.ceil(h/3)))
# Pesos mensuales medios por trimestre
blocks = y_pad.reshape(-1, 3)
totals = blocks.sum(axis=1, keepdims=True)
weights = np.divide(blocks, np.where(totals==0, 1.0, totals))
w = np.nan_to_num(weights.mean(axis=0))
if w.sum() == 0:
w = np.array([1/3, 1/3, 1/3], dtype=float)
# Desagregamos a meses
f_months = []
for qv in fq:
f_months.extend(list(w * qv))
return np.asarray(f_months[:h], dtype=float)
# Enrutador por nombre de modelo (ajustamos a los nombres usados en selección)
def get_model_spec_by_name(name: str):
s = (name or "").strip().lower()
# Mapeamos los nombres más probables de la selección / menús previos
if s in ["snaive12","snaive_12","seasonal_naive","snaive"]:
return ("baseline", "snaive12")
if s in ["naive1","naive","last"]:
return ("baseline", "naive1")
if s in ["medianaestacional","median_seasonal","mediana_estacional"]:
return ("baseline", "median_seasonal")
if s in ["theta","thetamodel","theta_model"]:
return ("theta", None)
if s in ["ets","ets_auto","etsauto"]:
return ("ets", None)
if s in ["holt","holt_damped","holt-amortiguado","holt_damped_trend"]:
return ("holt", None)
if s in ["arima","arima_auto","sarima_light","auto_arima"]:
return ("arima", None)
if s in ["croston","croston_classic"]:
return ("croston", "classic")
if s in ["sba","croston_sba"]:
return ("croston", "sba")
if s in ["tsb"]:
return ("tsb", None)
if s in ["adida","mapa","adida_mapa","imapa"]:
return ("adida_mapa", None)
# Si no reconocemos, devolvemos baseline robusta
return ("baseline", "median_seasonal")
def fit_predict_by_name(model_name: str, y_train, dates_train, h):
fam, var = get_model_spec_by_name(model_name)
try:
if fam == "baseline":
if var == "naive1":
return _baseline_naive1(y_train, h)
if var == "snaive12":
return _baseline_snaive12(y_train, dates_train, h)
if var == "median_seasonal":
return _baseline_median_seasonal(y_train, dates_train, h, m=12)
# Por seguridad
return _baseline_naive1(y_train, h)
if fam == "theta":
return _theta_predict(y_train, dates_train, h)
if fam == "ets":
return _ets_auto_predict(y_train, h)
if fam == "holt":
return _holt_damped_predict(y_train, h)
if fam == "arima":
return _arima_auto_light(y_train, h)
if fam == "croston":
return _croston_classic(y_train, h) if var == "classic" else _croston_sba(y_train, h)
if fam == "tsb":
return _tsb(y_train, h)
if fam == "adida_mapa":
return _adida_mapa(y_train, h)
except Exception as e:
log5(f"AVISO: fallo en {model_name}: {e}. Aplicamos fallback SNaive12.")
return _baseline_snaive12(y_train, dates_train, h)
# Si algo no entra en ninguna rama, devolvemos Naive1
return _baseline_naive1(y_train, h)
# Métricas auxiliares para pesos (eps para robustez)
def _safe_div(num, den, eps=1e-8):
return num / (den + eps)
Bloque 4 — Lógica por pareja y generación de ŷ_2024¶
# ------------------------------------------------
# Bloque 4 — Lógica por pareja y generación de ŷ_2024
# ------------------------------------------------
# Construimos índice por pareja para recuperar serie completa
full_df = pd.concat([train_full, test_full], ignore_index=True)
full_df = full_df.sort_values(PAIR_COLS + [DATECOL]).reset_index(drop=True)
grouped = full_df.groupby(PAIR_COLS, sort=False)
# Para comparar TSB vs SNaive12 en intermitentes, preparamos una vista de roll_agg
roll_key_cols = ["ID_BUILDING","FM_COST_TYPE","model_name","MASE12_mean"]
if set(roll_key_cols).issubset(roll_agg.columns):
roll_view = roll_agg[roll_key_cols].copy()
else:
roll_view = pd.DataFrame(columns=roll_key_cols)
# Preparamos buffers de salida
rows_out = []
alertas_rows = []
# Preparamos set de SOLO TEST (si existiera)
solo_test_set = set(map(tuple, pairs_solo_test[PAIR_COLS].drop_duplicates().values.tolist())) if not pairs_solo_test.empty else set()
# Generamos un diccionario de MASE12_mean por (pair, model_name) para ponderación
def mase12_lookup(bid, ctype, model_name):
if roll_view.empty:
return np.nan
sub = roll_view[(roll_view["ID_BUILDING"]==bid) & (roll_view["FM_COST_TYPE"]==ctype) & (roll_view["model_name"].str.lower()==str(model_name).lower())]
if sub.empty:
return np.nan
return float(sub["MASE12_mean"].iloc[0])
# Bucle principal por pareja
for _, prow in panel_pairs.iterrows():
bid, ctype = prow[PAIR_COLS[0]], prow[PAIR_COLS[1]]
route = str(prow.get("route", "GENERAL")).upper()
is_zero = int(prow.get(flag_col, 0)) == 1
top1 = prow.get("modelo_top1", None)
top2 = prow.get("modelo_top2", None)
# Extraemos serie completa y particionamos train/test explícitamente
try:
sub = grouped.get_group((bid, ctype)).sort_values(DATECOL)
except KeyError:
log5(f"AVISO: no encontramos datos para pareja {(bid, ctype)}. Saltamos.")
continue
# Train 2021–2023
tr = sub[(sub[DATECOL] >= pd.Timestamp("2021-01-01")) & (sub[DATECOL] <= pd.Timestamp("2023-12-01"))]
y_tr = tr[VALUE_TRAINTEST].astype(float).values
d_tr = tr[DATECOL].values
# Test 2024 (verdad-terreno si existe)
te = sub[(sub[DATECOL] >= pd.Timestamp("2024-01-01")) & (sub[DATECOL] <= pd.Timestamp("2024-12-01"))]
y_true_2024 = te.set_index(DATECOL)[VALUE_TRAINTEST].reindex(FUTURE_2024).fillna(np.nan).values
# Caso 1: cero estructural
if is_zero:
yhat = np.zeros(H, dtype=float)
for i, d in enumerate(FUTURE_2024):
rows_out.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA": d,
"yhat_modelo1": 0.0, "yhat_modelo2": np.nan, "yhat_combo": 0.0,
"fallback_flag": 1, "route": route,
"modelo_top1": "baseline_cero", "modelo_top2": None,
"combo_rule": "baseline_cero"
})
# Si observamos actividad real en 2024, registramos alerta
if not np.isnan(y_true_2024[i]) and y_true_2024[i] > 0:
alertas_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA": d,
"yhat_combo": 0.0, "y_true_2024": float(y_true_2024[i]),
"rule": "cero→actividad"
})
continue
# Caso 2: SOLO TEST (sin histórico en 2021–2023)
is_solo_test = (bid, ctype) in solo_test_set or (len(y_tr) == 0)
if is_solo_test:
yhat = _baseline_snaive12(y_tr, d_tr if len(d_tr)>0 else np.array([pd.Timestamp("2023-12-01")]), H)
yhat = np.maximum(yhat, 0.0)
for i, d in enumerate(FUTURE_2024):
rows_out.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA": d,
"yhat_modelo1": float(yhat[i]), "yhat_modelo2": np.nan, "yhat_combo": float(yhat[i]),
"fallback_flag": 1, "route": route,
"modelo_top1": "SNaive12", "modelo_top2": None,
"combo_rule": "fallback_snaive12"
})
continue
# Caso 3: intermitentes (prioridad TSB; si no mejora a SNaive12 en CV, usar SNaive12)
if route == "INTERMITENTE":
# Comparamos TSB vs SNaive12 con MASE12_mean si disponemos de roll_agg
mase_tsb = mase12_lookup(bid, ctype, "TSB")
mase_snv = mase12_lookup(bid, ctype, "SNaive12")
use_snaive = False
if not np.isnan(mase_tsb) and not np.isnan(mase_snv):
use_snaive = (mase_tsb >= mase_snv)
# Si no tenemos métricas, intentamos TSB por defecto
if use_snaive:
yhat1 = _baseline_snaive12(y_tr, d_tr, H)
yhat2 = np.full(H, np.nan)
combo = yhat1.copy()
combo_rule = "fallback_snaive12_cv"
fallback_flag = 1
m1_name, m2_name = "SNaive12", None
else:
# TSB como top1; si hay tiempo/queremos, combinamos con ADIDA/MAPA
yhat1 = _tsb(y_tr, H)
# Probamos ADIDA/MAPA como modelo 2
yhat2 = _adida_mapa(y_tr, H)
# Intentamos ponderación por 1/MASE12_mean si existe en roll_agg; si no, 50/50
m1_mase = mase12_lookup(bid, ctype, "TSB")
m2_mase = mase12_lookup(bid, ctype, "ADIDA_MAPA")
if not np.isnan(m1_mase) and not np.isnan(m2_mase) and m1_mase>0 and m2_mase>0:
w1 = 1.0 / m1_mase
w2 = 1.0 / m2_mase
s = w1 + w2
combo = (w1/s) * yhat1 + (w2/s) * yhat2
combo_rule = "weighted_by_inv_MASE12"
else:
combo = 0.5 * yhat1 + 0.5 * yhat2
combo_rule = "mean50_50"
fallback_flag = 0
m1_name, m2_name = "TSB", "ADIDA/MAPA"
# Aseguramos no negatividad
yhat1 = np.maximum(yhat1, 0.0)
if isinstance(yhat2, np.ndarray):
yhat2 = np.maximum(yhat2, 0.0)
combo = np.maximum(combo, 0.0)
# Volcamos 12 filas
for i, d in enumerate(FUTURE_2024):
rows_out.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA": d,
"yhat_modelo1": float(yhat1[i]),
"yhat_modelo2": (float(yhat2[i]) if isinstance(yhat2, np.ndarray) else np.nan),
"yhat_combo": float(combo[i]),
"fallback_flag": int(fallback_flag),
"route": route,
"modelo_top1": m1_name,
"modelo_top2": m2_name,
"combo_rule": combo_rule
})
continue
# Caso 4: generales (usamos top-1/top-2 de selección y combinamos)
# Si faltara el nombre de top1, caemos a SNaive12
if not isinstance(top1, str) or len(top1.strip()) == 0:
top1 = "SNaive12"
# Si top2 no existe, haremos combo=top1
if not isinstance(top2, str) or len(top2.strip()) == 0:
top2 = None
# Ejecutamos modelos sobre todo el train 2021–2023
yhat1 = fit_predict_by_name(top1, y_tr, d_tr, H)
yhat2 = fit_predict_by_name(top2, y_tr, d_tr, H) if top2 else None
# Aseguramos no negatividad
yhat1 = np.maximum(yhat1, 0.0)
if isinstance(yhat2, np.ndarray):
yhat2 = np.maximum(yhat2, 0.0)
# Combinación: por defecto 50/50; si tenemos MASE12_mean de ambos, ponderamos por 1/MASE12_mean
if isinstance(yhat2, np.ndarray):
m1_mase = mase12_lookup(bid, ctype, top1)
m2_mase = mase12_lookup(bid, ctype, top2)
if not np.isnan(m1_mase) and not np.isnan(m2_mase) and m1_mase>0 and m2_mase>0:
w1 = 1.0 / m1_mase
w2 = 1.0 / m2_mase
s = w1 + w2
combo = (w1/s) * yhat1 + (w2/s) * yhat2
combo_rule = "weighted_by_inv_MASE12"
else:
combo = 0.5 * yhat1 + 0.5 * yhat2
combo_rule = "mean50_50"
else:
combo = yhat1.copy()
combo_rule = "single_top1"
combo = np.maximum(combo, 0.0)
# Si algún modelo falló de forma silenciosa (NaNs, longitud), hacemos fallback a SNaive12
if (len(yhat1) != H) or np.isnan(combo).any():
log5(f"AVISO: predicciones inválidas en {(bid, ctype)}. Aplicamos fallback SNaive12.")
combo = _baseline_snaive12(y_tr, d_tr, H)
combo = np.maximum(combo, 0.0)
top1 = "SNaive12"; top2 = None; yhat1 = combo.copy(); yhat2 = None
combo_rule = "fallback_snaive12"
# Volcamos 12 filas
for i, d in enumerate(FUTURE_2024):
rows_out.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA": d,
"yhat_modelo1": float(yhat1[i]),
"yhat_modelo2": (float(yhat2[i]) if isinstance(yhat2, np.ndarray) else np.nan),
"yhat_combo": float(combo[i]),
"fallback_flag": 0 if combo_rule in ["mean50_50","weighted_by_inv_MASE12","single_top1"] else 1,
"route": route,
"modelo_top1": top1,
"modelo_top2": top2,
"combo_rule": combo_rule
})
Bloque 5 — Guardado de resultados¶
# ----------------------------------------
# Bloque 5 — Guardado de resultados
# ----------------------------------------
# Construimos DataFrame de salida y aseguramos orden de columnas
cols_out = [
PAIR_COLS[0], PAIR_COLS[1], "FECHA",
"yhat_modelo1", "yhat_modelo2", "yhat_combo",
"fallback_flag", "route", "modelo_top1", "modelo_top2", "combo_rule"
]
preds_df = pd.DataFrame(rows_out)
if not preds_df.empty:
preds_df = preds_df[cols_out].sort_values(PAIR_COLS + ["FECHA"]).reset_index(drop=True)
OUT_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv")
preds_df.to_csv(OUT_PREDS, sep=CSV_SEP, index=False)
log5(f"Guardado: {OUT_PREDS} con {len(preds_df)} filas.")
# Guardamos alertas cero→actividad si existen
if len(alertas_rows) > 0:
alertas_df = pd.DataFrame(alertas_rows)[PAIR_COLS + ["FECHA","yhat_combo","y_true_2024","rule"]]
OUT_ALERT = os.path.join(RUTA_METRICAS, "alertas_cero_estructural_2024.csv")
alertas_df.to_csv(OUT_ALERT, sep=CSV_SEP, index=False)
log5(f"Guardado: {OUT_ALERT} con {len(alertas_df)} alertas.")
else:
log5("No se registraron alertas cero→actividad en 2024.")
# (Opcional) Resumen por route y % fallback
if not preds_df.empty:
resumen = (preds_df
.groupby("route", as_index=False)
.agg(n_rows=("FECHA","count"),
n_pairs=(PAIR_COLS[0], "nunique"))
)
# Calculamos %fallback por route
fb = preds_df.groupby("route", as_index=False)["fallback_flag"].mean().rename(columns={"fallback_flag":"fallback_rate"})
resumen = resumen.merge(fb, on="route", how="left")
OUT_RES = os.path.join(RUTA_METRICAS, "preds_resumen_2024.csv")
resumen.to_csv(OUT_RES, sep=CSV_SEP, index=False)
log5(f"Guardado: {OUT_RES}")
# ----------------------------------------
# Bloque 6 — Punto de control E
# ----------------------------------------
# Validamos cobertura: nº parejas y 12 filas por pareja
pair_counts = preds_df.groupby(PAIR_COLS)["FECHA"].nunique().reset_index(name="n_meses")
missing_12 = pair_counts[pair_counts["n_meses"] != 12]
pred_pairs = pair_counts.shape[0]
expected_pairs = panel_pairs[PAIR_COLS].drop_duplicates().shape[0]
missing_pairs = expected_pairs - pred_pairs
fb_global = float(preds_df["fallback_flag"].mean()) if len(preds_df) else 0.0
fb_by_route = (preds_df.groupby("route")["fallback_flag"].mean().reset_index() if len(preds_df) else pd.DataFrame(columns=["route","fallback_flag"]))
log5(f"Punto E — parejas predichas={pred_pairs} / esperadas={expected_pairs} | missing_pairs={missing_pairs}")
log5(f"Punto E — %fallback global={fb_global:.2%}")
if not fb_by_route.empty:
for _, r in fb_by_route.iterrows():
log5(f"Punto E — %fallback route={r['route']}: {float(r['fallback_flag']):.2%}")
if not missing_12.empty:
log5(f"Punto E — parejas sin 12 meses: {len(missing_12)}")
# Mostramos hasta 10 para inspección
log5("Ejemplos sin 12 meses (hasta 10): " + ", ".join(map(str, missing_12[PAIR_COLS].head(10).values.tolist())))
else:
log5("Punto E — todas las parejas tienen 12 meses (enero–diciembre 2024).")
log5("Paso 5 finalizado.")
[2025-09-21T23:11:07] Iniciamos Paso 5 — Ensembles y predicción 2024. [2025-09-21T23:11:07] Población Paso 5 — total=2429 | INTERMITENTE=1397 | GENERAL=1032 | all_zero=104 [2025-09-21T23:14:35] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_por_serie_2024.csv con 29148 filas. [2025-09-21T23:14:35] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/alertas_cero_estructural_2024.csv con 73 alertas. [2025-09-21T23:14:35] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/preds_resumen_2024.csv [2025-09-21T23:14:35] Punto E — parejas predichas=2429 / esperadas=2429 | missing_pairs=0 [2025-09-21T23:14:35] Punto E — %fallback global=34.79% [2025-09-21T23:14:35] Punto E — %fallback route=GENERAL: 0.00% [2025-09-21T23:14:35] Punto E — %fallback route=INTERMITENTE: 60.49% [2025-09-21T23:14:35] Punto E — todas las parejas tienen 12 meses (enero–diciembre 2024). [2025-09-21T23:14:35] Paso 5 finalizado.
Interpretación paso 5:¶
Lectura rápida del Punto E y coherencias
Cobertura: parejas predichas=2429 / esperadas=2429, missing_pairs=0. Hemos cubierto el 100% del panel.
Filas: 29148 filas en preds_por_serie_2024.csv. Cuadra exactamente con 2429 * 12 = 29148. Correcto.
Meses por serie: “todas las parejas tienen 12 meses (enero-diciembre 2024)”. La malla temporal está íntegra.
Fallback:
Global: 34,79%. Un tercio de las filas han ido por alguna regla de fallback.
GENERAL: 0,00%. Excelente: en rutas estables hemos podido usar los top-2/ensemble sin caer a fallback.
INTERMITENTE: 60,49%. Esperado por dos motivos: i) alta intermitencia con muchas series donde SNaive12 supera a TSB en CV, y ii) presencia de “cero estructural” que fuerza baseline.
Alertas “cero → actividad”: 73 alertas registradas. Indican meses de 2024 con gasto positivo en series tratadas como cero estructural. Son valiosas para revisar con negocio.
Qué concluimos
Hemos cumplido el objetivo principal del Paso 5: predicción 2024 completa y estable con ensembles, y trazabilidad vía logs y CSVs.
La ruta GENERAL queda plenamente modelizada sin fallback, lo que valida el menú de modelos y la selección del Paso 4.
En INTERMITENTE, el fallback alto es coherente: cuando TSB no mejora a SNaive12 en CV, aplicamos la regla pactada. Además, las series “todo-cero” empujan ese porcentaje hacia arriba.
Las 73 alertas nos ayudan a detectar cambios de patrón: series que fueron “cero” en 2021-2023 pero muestran actividad en 2024.
Paso 6 - Comparación contra SNaive12 y consolidación de modelo_final por serie¶
Es el control de calidad final que convierte todo lo aprendido en los pasos 2–5 en una decisión operativa por serie (ID_BUILDING, FM_COST_TYPE): qué modelo queda definitivamente como “modelo_final” para 2024 y por qué. No entrena nada nuevo; audita, compara y consolida
¿Qué problema resuelve?
Aunque en el Paso 4 ya elegimos un top-1 (a partir de la validación rolling en 2023) y en el Paso 5 generamos predicciones con ensembles, necesitamos una garantía mínima de valor:
¿Aporta realmente el modelo elegido más que una estrategia estacional ingenua que copia el valor del mismo mes del año anterior (SNaive12)?
El Paso 6 responde a esa pregunta con datos del año objetivo (2024) y aplica reglas de gobernanza para no desplegar modelos que no superen a la referencia estacional.
¿Por qué usar SNaive12 como ancla?
Es robusto para datos mensuales con estacionalidad (replica el patrón mes-a-mes del último año).
Es interpretable para negocio (comparar con “lo del año pasado”).
Pone un listón mínimo: si no superamos SNaive12, no merece la complejidad/variabilidad de un modelo más sofisticado.
¿Qué hace, exactamente?
Recupera el desempeño de SNaive12 en 2024 desde metrics_baselines_cv.csv.
Cruza esa métrica con el top-1 del Paso 4 (MASE12_top1, modelo_top1 en seleccion_modelos_cv.csv).
Calcula:
delta_MASE12_vs_SNaive12 = MASE12_top1 - MASE12_SNaive12_2024
winner_beats_snaive12 = 1 si delta < 0; 0 en otro caso
Aplica la regla de reemplazo: si ningún candidato de la serie mejora a SNaive12 (mirando rolling_metrics_agg.csv), forzamos:
modelo_top1 = "SNaive12"
modelo_top2 = mejor_no_baseline (si existe, para documentación/comparativas). Esto evita “sobre-modelar” series donde la estacional ingenua ya es tan buena o mejor.
Respeta casos especiales (máxima prioridad):
Cero estructural (flag_all_zero_train=1): modelo_final = "baseline_cero" y se monitoriza si 2024 muestra actividad.
Solo test (sin histórico 2021–2023): modelo_final = "SNaive12".
Consolida un catálogo final por serie con: modelo_final, motivo de decisión, referencia SNaive12, y trazabilidad.
Registra un log claro: % de series que baten a SNaive12, nº de reemplazos, nº de cero estructural/solo test, y alertas cero→actividad (continuidad del Paso 5).
¿Cómo se apoya en lo anterior?
Segmentación (Paso 2–3): mantiene rutas INTERMITENTE vs GENERAL y respeta flag_all_zero_train.
Selección (Paso 4): parte del top-1 obtenido con rolling (donde ~70% ya batía a SNaive12), pero ahora lo valida contra SNaive12 en 2024 y aplica la cláusula de reemplazo si toca.
Predicciones (Paso 5): recoge la trazabilidad (incluidas las 73 alertas “cero→actividad”) y la incorpora al log del Paso 6 para gobernanza.
Entradas y salidas (para producción y auditoría)
Entradas
seleccion_modelos_cv.csv (Paso 4), rolling_metrics_agg.csv (Paso 4),
metrics_baselines_cv.csv (Paso 1, SNaive12_2024),
segmentation_intermitencia_step3.csv (bandera cero estructural),
pairs_solo_test.csv (si existe).
Salidas
paso6_snaive_vs_top1.csv: comparación y regla aplicada por serie.
modelo_final_paso6.csv: decisión final (modelo_final + motivo + referencia SNaive12).
estrategia3_step6.log: métricas de decisión, % mejoras vs SNaive12, reemplazos, cero estructural, solo test, y alertas.
Beneficios para negocio y despliegue
Parquedad y fiabilidad: si un modelo no gana a SNaive12, preferimos SNaive12 (menos complejidad, menos riesgo).
Trazabilidad: cada decisión queda explicada y auditable.
Coherencia operativa: los casos de cero estructural y solo test se gestionan de forma específica y monitorizada.
Continuidad analítica: enlaza validación (Paso 4), predicción (Paso 5) y decisión de despliegue (Paso 6).
Mini-ejemplos
Serie A (GENERAL): MASE12_top1 = 0.92, MASE12_SNaive12_2024 = 1.10 → Conserva top-1 (winner_beats_snaive12=1).
Serie B (INTERMITENTE): MASE12_top1 = 1.25, SNaive12 = 1.05, y ningún candidato < 1.05 → Reemplaza por SNaive12.
Serie C (cero estructural): 36 meses a 0 → modelo_final = baseline_cero y alerta si aparece gasto en 2024.
En suma: el Paso 6 es el candado final de valor añadido — garantiza que lo que vamos a “poner en producción” mejora una referencia estacional sensata, y cuando no lo hace, elige la opción más robusta con una explicación clara.
Bloque 1 - Parámetros y rutas¶
# ============================================================
# ESTRATEGIA 3 — PASO 6
# Comparación contra SNaive12 y consolidación de modelo_final por serie
# ============================================================
# En este paso:
# - Recuperamos MASE12 de SNaive12 en 2024
# - Comparamos con el top-1 seleccionado en Paso 4
# - Calculamos delta y winner flag
# - Aplicamos la regla de reemplazo por SNaive12 si ningún candidato mejora
# - Respetamos casos especiales (cero estructural y solo test)
# - Guardamos tablas finales y dejamos trazas en un log
# ============================================================
# Fijamos semilla global para reproducibilidad de cualquier aleatoriedad interna.
np.random.seed(7)
# =========================
# Bloque 1) Parámetros y rutas
# =========================
# Reutilizamos variables globales si existen; si no, definimos valores por defecto seguros.
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
PAIR_COLS = globals().get("PAIR_COLS", ["ID_BUILDING","FM_COST_TYPE"])
DATECOL = globals().get("DATECOL", "FECHA")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_RESULT = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_RESULT, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
LOG_PATH_STEP6 = os.path.join(RUTA_LOGS, "estrategia3_step6.log")
# Ficheros de entrada esperados
PATH_SEL_CV = os.path.join(RUTA_METRICAS, "seleccion_modelos_cv.csv")
PATH_ROLL_AGG = os.path.join(RUTA_METRICAS, "rolling_metrics_agg.csv")
PATH_BASE = os.path.join(RUTA_METRICAS, "metrics_baselines_cv.csv")
PATH_SEG_ENR = os.path.join(RUTA_METRICAS, "segmentation_intermitencia_step3.csv")
PATH_SOLOTEST = os.path.join(RUTA_METRICAS, "pairs_solo_test.csv") # opcional
PATH_ALERTAS = os.path.join(RUTA_METRICAS, "alertas_cero_estructural_2024.csv")# opcional del Paso 5
# Ficheros de salida de este paso
PATH_SNAIVE_VS_TOP1 = os.path.join(RUTA_METRICAS, "paso6_snaive_vs_top1.csv")
PATH_MODELO_FINAL = os.path.join(RUTA_METRICAS, "modelo_final_paso6.csv")
Bloque 2 - Funciones auxiliares¶
# =========================
# Bloque 2) Funciones auxiliares
# =========================
def log6(msg: str):
"""Escribimos mensajes del Paso 6 en el log y también en consola para trazabilidad."""
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP6, "a", encoding="utf-8") as f:
f.write(line + "\n")
def _read_csv(path: str, required: bool = True, sep: str = CSV_SEP) -> pd.DataFrame:
"""Leemos un CSV con el separador del proyecto. Si no existe y es opcional, devolvemos DF vacío."""
if os.path.exists(path):
return pd.read_csv(path, sep=sep)
if required:
raise FileNotFoundError(f"Falta el archivo requerido: {path}")
log6(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _to_float_safe(s):
"""Convertimos una serie a float de forma robusta."""
return pd.to_numeric(s, errors="coerce")
def _is_baseline_name(name: str) -> bool:
"""Determinamos si un nombre de modelo es baseline para excluirlo al elegir mejor_no_baseline."""
if not isinstance(name, str):
return False
s = name.strip().lower()
baselines = {
"naive1", "snaive12", "medianaestacional", "medianaestac",
"baseline_cero", "perfilescaso", "perfil_escaso", "perfilsparso",
"perfil_escaso_uniforme"
}
return s in baselines
def _mejor_no_baseline(roll_agg_pair: pd.DataFrame) -> str | None:
"""Elegimos el mejor candidato no-baseline por MASE12_mean (desempate WAPE_mean)."""
if roll_agg_pair.empty:
return None
df = roll_agg_pair.copy()
df = df[~df["model_name"].apply(_is_baseline_name)]
if df.empty:
return None
df = df.sort_values(["MASE12_mean", "WAPE_mean"], ascending=[True, True])
return str(df.iloc[0]["model_name"])
def _min_mase12_no_baseline(roll_agg_pair: pd.DataFrame) -> float:
"""Calculamos el mínimo MASE12_mean entre no-baselines; devolvemos NaN si no hay candidatos."""
df = roll_agg_pair.copy()
df = df[~df["model_name"].apply(_is_baseline_name)]
if df.empty:
return np.nan
return float(df["MASE12_mean"].min())
def _attach_route(sel_df: pd.DataFrame, seg_df: pd.DataFrame) -> pd.DataFrame:
"""Nos aseguramos de tener la columna route en la selección (si no está, la inferimos de segment)."""
has_route = "route" in sel_df.columns
if has_route:
return sel_df
# Si no estuviera, inferimos desde la segmentación (segment GENERAL/INTERMITENTE).
aux = seg_df[PAIR_COLS + ["segment"]].drop_duplicates().rename(columns={"segment": "route"})
out = sel_df.merge(aux, on=PAIR_COLS, how="left")
return out
Bloque 3 - Carga de artefactos¶
# =========================
# Bloque 3) Carga de artefactos
# =========================
log6("Iniciamos PASO 6 — Carga de artefactos.")
sel = _read_csv(PATH_SEL_CV, required=True)
roll = _read_csv(PATH_ROLL_AGG, required=True)
base = _read_csv(PATH_BASE, required=True)
seg = _read_csv(PATH_SEG_ENR, required=True)
# Cargas opcionales
pairs_solo_test = _read_csv(PATH_SOLOTEST, required=False)
alertas_df = _read_csv(PATH_ALERTAS, required=False)
# Tipamos métricas clave
for col in ["MAE_mean","WAPE_mean","SMAPE_mean","MASE1_mean","MASE12_mean"]:
if col in roll.columns:
roll[col] = _to_float_safe(roll[col])
for col in ["MAE","WAPE","SMAPE","MASE1","MASE12","MASE12_top1","MASE12_top2"]:
if col in sel.columns:
sel[col] = _to_float_safe(sel[col])
# Aseguramos que selección tenga route (si viniera sin ella)
sel = _attach_route(sel, seg)
Bloque 4 - Recuperar SNaive12 (2024) y unir con selección¶
# =========================
# Bloque 4) Recuperar SNaive12 (2024) y unir con selección
# =========================
# Extraemos SNaive12 desde metrics_baselines_cv.csv (Paso 1)
snaive = base.copy()
if "baseline" not in snaive.columns:
raise ValueError("El archivo metrics_baselines_cv.csv no tiene columna 'baseline'.")
snaive = snaive[snaive["baseline"].str.strip().str.lower() == "snaive12"]
# Nos quedamos con la métrica MASE12 como referencia 2024 para la comparación
snaive = snaive[PAIR_COLS + ["MASE12"]].rename(columns={"MASE12": "MASE12_SNaive12_2024"})
snaive["MASE12_SNaive12_2024"] = _to_float_safe(snaive["MASE12_SNaive12_2024"])
# Unimos con la selección del Paso 4
merged = sel.merge(snaive, on=PAIR_COLS, how="left")
# Calculamos delta y winner flag (si no hay SNaive12_2024, el winner queda en 0 por cautela)
merged["delta_MASE12_vs_SNaive12"] = _to_float_safe(merged["MASE12_top1"]) - _to_float_safe(merged["MASE12_SNaive12_2024"])
merged["winner_beats_snaive12"] = ((merged["delta_MASE12_vs_SNaive12"] < 0).astype(int)).fillna(0).astype(int)
merged["regla_aplicada"] = "mantener_top1"
# Dejamos tabla base para la salida 1 (se actualizará con la regla de reemplazo si aplica)
cols_out_step6 = [
*PAIR_COLS, "route", "modelo_top1", "modelo_top2",
"MASE12_top1", "MASE12_SNaive12_2024", "delta_MASE12_vs_SNaive12",
"winner_beats_snaive12", "regla_aplicada"
]
for c in ["MASE12_top1","MASE12_SNaive12_2024","delta_MASE12_vs_SNaive12"]:
if c in merged.columns:
merged[c] = _to_float_safe(merged[c])
Bloque 5 - Aplicación de reglas por pareja¶
# =========================
# Bloque 5) Aplicación de reglas por pareja
# =========================
log6("Aplicamos reglas de decisión por pareja (cero estructural, solo test y reemplazo por SNaive12).")
# Preparamos índices para acceso rápido
roll_idx = roll.set_index(PAIR_COLS, drop=False)
seg_idx = seg.set_index(PAIR_COLS, drop=False)
solo_test_set = set(map(tuple, pairs_solo_test[PAIR_COLS].drop_duplicates().values.tolist())) if not pairs_solo_test.empty else set()
# Vamos a construir la decisión final por pareja
final_rows = []
# También iremos actualizando 'merged' para reflejar si forzamos SNaive12 en modelo_top1 (regla_aplicada)
merged = merged.copy()
for i, r in merged.iterrows():
bid, ctype = r[PAIR_COLS[0]], r[PAIR_COLS[1]]
route = r.get("route", "GENERAL")
top1 = r.get("modelo_top1", None)
top2 = r.get("modelo_top2", None)
# Recuperamos bandera cero estructural
flag_cero = 0
try:
flag_cero = int(seg_idx.loc[(bid, ctype)].get("flag_all_zero_train", 0))
except Exception:
flag_cero = 0
# Caso 1: cero estructural (prioridad máxima)
if flag_cero == 1:
final_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "route": route,
"modelo_final": "baseline_cero",
"motivo_final": "cero_estructural",
"modelo_top2_final": None,
"referencia_SNaive12": float(r.get("MASE12_SNaive12_2024")) if pd.notna(r.get("MASE12_SNaive12_2024")) else np.nan,
"delta_MASE12_vs_SNaive12": float(r.get("delta_MASE12_vs_SNaive12")) if pd.notna(r.get("delta_MASE12_vs_SNaive12")) else np.nan
})
# No cambiamos merged['modelo_top1'] en este caso; el resultado final manda en modelo_final
continue
# Caso 2: SOLO TEST (sin histórico 2021–2023)
is_solo_test = (bid, ctype) in solo_test_set
if is_solo_test:
final_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "route": route,
"modelo_final": "SNaive12",
"motivo_final": "solo_test",
"modelo_top2_final": None,
"referencia_SNaive12": float(r.get("MASE12_SNaive12_2024")) if pd.notna(r.get("MASE12_SNaive12_2024")) else np.nan,
"delta_MASE12_vs_SNaive12": float(r.get("delta_MASE12_vs_SNaive12")) if pd.notna(r.get("delta_MASE12_vs_SNaive12")) else np.nan
})
continue
# Caso 3: comparación y posible reemplazo por SNaive12
ref_snaive = r.get("MASE12_SNaive12_2024")
if pd.isna(ref_snaive):
# Si no tenemos SNaive12_2024, mantenemos top1 por prudencia y lo dejamos registrado
final_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "route": route,
"modelo_final": top1,
"motivo_final": "sin_snaive12_2024",
"modelo_top2_final": top2,
"referencia_SNaive12": np.nan,
"delta_MASE12_vs_SNaive12": np.nan
})
merged.loc[i, "regla_aplicada"] = "sin_snaive12_2024"
continue
# Recuperamos performance agregada rolling para esta pareja
try:
roll_pair = roll_idx.loc[(bid, ctype)]
roll_pair = roll_pair if isinstance(roll_pair, pd.DataFrame) else pd.DataFrame([roll_pair])
except KeyError:
roll_pair = pd.DataFrame(columns=roll.columns)
# Calculamos si algún candidato NO-BASELINE mejora a SNaive12 (usamos MASE12_mean de CV)
min_mase_nb = _min_mase12_no_baseline(roll_pair)
mejora_existente = (pd.notna(min_mase_nb) and min_mase_nb < float(ref_snaive))
if mejora_existente:
# Conservamos el top-1 de la selección y registramos el motivo según delta
motivo = "mejora_vs_SNaive12" if int(r.get("winner_beats_snaive12", 0)) == 1 else "mantener_top1"
final_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "route": route,
"modelo_final": top1,
"motivo_final": motivo,
"modelo_top2_final": top2,
"referencia_SNaive12": float(ref_snaive),
"delta_MASE12_vs_SNaive12": float(r.get("delta_MASE12_vs_SNaive12")) if pd.notna(r.get("delta_MASE12_vs_SNaive12")) else np.nan
})
else:
# Ningún candidato mejora: forzamos SNaive12 como top1 y elegimos mejor_no_baseline como top2_final
mejor_nb = _mejor_no_baseline(roll_pair)
merged.loc[i, "modelo_top1"] = "SNaive12"
merged.loc[i, "regla_aplicada"] = "reemplazo_por_SNaive12"
final_rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "route": route,
"modelo_final": "SNaive12",
"motivo_final": "reemplazo_por_SNaive12",
"modelo_top2_final": mejor_nb,
"referencia_SNaive12": float(ref_snaive),
"delta_MASE12_vs_SNaive12": float(r.get("delta_MASE12_vs_SNaive12")) if pd.notna(r.get("delta_MASE12_vs_SNaive12")) else np.nan
})
Bloque 6 - Guardado de salidas¶
# =========================
# Bloque 6) Guardado de salidas
# =========================
# Salida 1: comparación top1 vs SNaive12 con regla aplicada
paso6_df = merged[cols_out_step6].copy()
paso6_df.to_csv(PATH_SNAIVE_VS_TOP1, sep=CSV_SEP, index=False)
# Salida 2: modelo final por pareja
modelo_final_df = pd.DataFrame(final_rows)[
PAIR_COLS + ["route","modelo_final","motivo_final","modelo_top2_final","referencia_SNaive12","delta_MASE12_vs_SNaive12"]
].copy()
modelo_final_df = modelo_final_df.sort_values(PAIR_COLS).reset_index(drop=True)
modelo_final_df.to_csv(PATH_MODELO_FINAL, sep=CSV_SEP, index=False)
log6(f"Guardado: {PATH_SNAIVE_VS_TOP1} (comparación y reglas aplicadas)")
log6(f"Guardado: {PATH_MODELO_FINAL} (modelo_final por pareja)")
Bloque 7 - Punto de control F — métricas y log¶
# =========================
# Bloque 7) Punto de control F — métricas y log
# =========================
n_pairs_total = modelo_final_df[PAIR_COLS].drop_duplicates().shape[0]
n_zero = (modelo_final_df["motivo_final"] == "cero_estructural").sum()
n_solo_test = (modelo_final_df["motivo_final"] == "solo_test").sum()
n_reemplazos = (modelo_final_df["motivo_final"] == "reemplazo_por_SNaive12").sum()
# % donde top1 supera a SNaive12 (solo cuando tenemos referencia y no casos especiales)
work = paso6_df.merge(modelo_final_df[PAIR_COLS + ["motivo_final"]], on=PAIR_COLS, how="left")
mask_eval = (~work["MASE12_SNaive12_2024"].isna()) & (~work["motivo_final"].isin(["cero_estructural","solo_test"]))
pct_winners = float(work.loc[mask_eval, "winner_beats_snaive12"].mean()) if mask_eval.sum() > 0 else np.nan
# Nº de alertas cero→actividad (si existe el artefacto del Paso 5)
n_alertas = int(alertas_df.shape[0]) if not alertas_df.empty else 0
log6(f"Punto F — parejas totales en modelo_final: {n_pairs_total}")
log6(f"Punto F — % top1 que supera a SNaive12 (en pares evaluables): {('%.2f' % (pct_winners*100)) if not pd.isna(pct_winners) else 'NA'}%")
log6(f"Punto F — nº reemplazos por SNaive12: {n_reemplazos}")
log6(f"Punto F — nº cero estructural: {n_zero}")
log6(f"Punto F — nº solo test: {n_solo_test}")
log6(f"Punto F — nº alertas cero→actividad (2024): {n_alertas}")
# Ejemplos (hasta 10) por categoría para auditoría
def _ejemplos(df: pd.DataFrame, mask, cols_show, titulo: str):
sub = df.loc[mask, cols_show].head(10)
if not sub.empty:
log6(f"Ejemplos — {titulo}:")
for _, row in sub.iterrows():
log6(" " + ", ".join([f"{c}={row[c]}" for c in cols_show]))
cols_show_pairs = PAIR_COLS + ["route"]
_ejemplos(modelo_final_df, modelo_final_df["motivo_final"]=="reemplazo_por_SNaive12", cols_show_pairs+["modelo_final","modelo_top2_final"], "reemplazos por SNaive12")
_ejemplos(modelo_final_df, modelo_final_df["motivo_final"]=="cero_estructural", cols_show_pairs+["modelo_final"], "cero estructural")
_ejemplos(modelo_final_df, modelo_final_df["motivo_final"]=="solo_test", cols_show_pairs+["modelo_final"], "solo test")
_ejemplos(work, mask_eval & (work["winner_beats_snaive12"]==1), cols_show_pairs+["winner_beats_snaive12"], "top1 supera a SNaive12")
print("\n== Paso 6 completado ==")
print(f"Comparación guardada en: {PATH_SNAIVE_VS_TOP1}")
print(f"Modelo final guardado en: {PATH_MODELO_FINAL}")
print(f"Log: {LOG_PATH_STEP6}")
[2025-09-21T23:33:35] Iniciamos PASO 6 — Carga de artefactos. [2025-09-21T23:33:35] Aplicamos reglas de decisión por pareja (cero estructural, solo test y reemplazo por SNaive12). [2025-09-21T23:33:39] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/paso6_snaive_vs_top1.csv (comparación y reglas aplicadas) [2025-09-21T23:33:39] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/modelo_final_paso6.csv (modelo_final por pareja) [2025-09-21T23:33:39] Punto F — parejas totales en modelo_final: 2429 [2025-09-21T23:33:39] Punto F — % top1 que supera a SNaive12 (en pares evaluables): 78.07% [2025-09-21T23:33:39] Punto F — nº reemplazos por SNaive12: 660 [2025-09-21T23:33:39] Punto F — nº cero estructural: 104 [2025-09-21T23:33:39] Punto F — nº solo test: 0 [2025-09-21T23:33:39] Punto F — nº alertas cero→actividad (2024): 73 [2025-09-21T23:33:39] Ejemplos — reemplazos por SNaive12: [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Licencias, route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=ADIDA_MAPA [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Obras, route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=TSB [2025-09-21T23:33:39] ID_BUILDING=59, FM_COST_TYPE=Servicios Ctto., route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=ADIDA_MAPA [2025-09-21T23:33:39] ID_BUILDING=74, FM_COST_TYPE=Servicios Ctto., route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=TSB [2025-09-21T23:33:39] ID_BUILDING=74, FM_COST_TYPE=Servicios Extra, route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=TSB [2025-09-21T23:33:39] ID_BUILDING=104, FM_COST_TYPE=Suministros, route=GENERAL, modelo_final=SNaive12, modelo_top2_final=Theta [2025-09-21T23:33:39] ID_BUILDING=105, FM_COST_TYPE=Servicios Extra, route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=SBA [2025-09-21T23:33:39] ID_BUILDING=117, FM_COST_TYPE=Licencias, route=INTERMITENTE, modelo_final=SNaive12, modelo_top2_final=TSB [2025-09-21T23:33:39] ID_BUILDING=117, FM_COST_TYPE=Mtto. Correctivo, route=GENERAL, modelo_final=SNaive12, modelo_top2_final=Theta [2025-09-21T23:33:39] ID_BUILDING=121, FM_COST_TYPE=Mtto. Correctivo, route=GENERAL, modelo_final=SNaive12, modelo_top2_final=Holt_damped [2025-09-21T23:33:39] Ejemplos — cero estructural: [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Eficiencia Energética, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=9, FM_COST_TYPE=Eficiencia Energética, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=18, FM_COST_TYPE=Eficiencia Energética, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=59, FM_COST_TYPE=Obras, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=62, FM_COST_TYPE=Licencias, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=74, FM_COST_TYPE=Licencias, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=127, FM_COST_TYPE=Servicios Extra, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=129, FM_COST_TYPE=Eficiencia Energética, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=137, FM_COST_TYPE=Obras, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] ID_BUILDING=146, FM_COST_TYPE=Eficiencia Energética, route=INTERMITENTE, modelo_final=baseline_cero [2025-09-21T23:33:39] Ejemplos — top1 supera a SNaive12: [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Licencias, route=INTERMITENTE, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Mtto. Contratos, route=GENERAL, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Mtto. Correctivo, route=GENERAL, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Obras, route=INTERMITENTE, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Servicios Ctto., route=GENERAL, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Servicios Extra, route=INTERMITENTE, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=2, FM_COST_TYPE=Suministros, route=GENERAL, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=9, FM_COST_TYPE=Licencias, route=INTERMITENTE, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=9, FM_COST_TYPE=Mtto. Contratos, route=GENERAL, winner_beats_snaive12=1 [2025-09-21T23:33:39] ID_BUILDING=9, FM_COST_TYPE=Mtto. Correctivo, route=GENERAL, winner_beats_snaive12=1 == Paso 6 completado == Comparación guardada en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/paso6_snaive_vs_top1.csv Modelo final guardado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/modelo_final_paso6.csv Log: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/LOGS/estrategia3_step6.log
Interpretación Paso 6¶
Lectura de resultados
Confirmamos 2429 parejas en modelo_final_paso6.csv. Cuadra con el universo de train y con los pasos 2–5.
% de top-1 que supera a SNaive12 (evaluables): 78,07%. Mejoramos claramente la referencia del Paso 4 (≈69,8%). Interpretamos que, al contrastar con 2024, el top-1 mantiene e incluso mejora su ventaja frente a SNaive12 en la mayoría de casos.
Reemplazos por SNaive12: 660 parejas → ~27,17% del total. Es un volumen razonable dado el peso del segmento intermitente y los casos de bajo volumen; la regla de gobernanza está funcionando.
Cero estructural: 104 parejas. Cuadra con lo detectado en el Paso 3. Seguimos usando pronóstico fijo = 0 y conservamos la lógica de alerta si aparece actividad.
Solo test: 0. Es coherente con que este catálogo final cubre el panel de train (2429). Las ≈“solo test” que vimos en Paso 0/2 no forman parte de este universo de decisión de modelo_final.
Alertas “cero → actividad”: 73. Reconfirmamos la cifra del Paso 5; conviene priorizar su revisión con negocio.
Lo que nos dicen los ejemplos del log
Los reemplazos aparecen sobre todo en INTERMITENTE (p. ej., Licencias, Obras, Servicios Extra, Servicios Ctto.), pero también vemos algunos GENERAL (p. ej., Suministros o Mantenimiento Correctivo en ciertos edificios). En estos casos, el desempeño agregado de los candidatos en CV no superó a SNaive12 y la regla forzó la sustitución, dejando un “mejor_no_baseline” como referencia secundaria.
El bloque de cero estructural muestra categorías como Eficiencia Energética y Obras completamente en cero 2021–2023, tal y como veníamos observando; mantenemos baseline_cero y monitorización.
En los top-1 que superan a SNaive12 aparecen tanto rutas INTERMITENTE como GENERAL; refuerza que el menú de candidatos está aportando valor más allá de la estacional ingenua.
Criterios de aceptación del paso
Tenemos una fila por pareja en modelo_final_paso6.csv.
Se respeta la prioridad de reglas: cero estructural > solo test > comparación vs SNaive12. En este universo no hay solo test y los cero estructural están aislados.
El log ya incluye los totales, ejemplos por categoría y recuento de alertas. Cumple lo que nos pedimos.
Paso 7 - Interfaces de modelos (mínimo viable)¶
Con el Paso 7 pretendemos construir un módulo mínimo viable de interfaces de modelos que funcione como una capa intermedia entre los datos de entrenamiento y las predicciones del proyecto de OPEX FM.
En concreto, este paso busca:
Unificación de la firma de modelos
Todos los modelos (estadísticos clásicos, intermitentes y baselines) deben poder llamarse de la misma forma mediante una función única fit_predict(...).
Esto simplifica la orquestación en los pasos posteriores (ensembles, comparación de métricas, despliegue en la web).
Normalización de entradas y salidas
Entrada: series históricas de costes (y_train + dates_train).
Salida: vector np.ndarray(horizon,) de predicciones futuras, siempre no negativas y sin NaNs.
Incluye normalización de fechas, gestión de valores vacíos, clipping ≥ 0.
Compatibilidad con todas las familias del proyecto
Modelos clásicos: Theta, ETS, Holt, ARIMA.
Modelos para series intermitentes: Croston (classic/SBA), TSB.
Estrategias de agregación/desagregación: ADIDA/MAPA.
Baselines robustos: Naive1, SNaive12, Mediana estacional, Perfil escaso uniforme, Cero.
Fallbacks y robustez
Si un modelo no está disponible (ej. statsmodels no instalado) o falla el ajuste, se sustituye automáticamente por un baseline apropiado.
Se evita que el pipeline se rompa.
Portabilidad y bajo acoplamiento
Sin dependencias externas innecesarias (solo numpy, pandas, y opcionalmente statsmodels).
Código autocontenido, fácil de integrar en la futura aplicación web del proyecto.
Testing sintético mínimo
Generar series de prueba (suaves, estacionales, intermitentes, todo-ceros).
Verificar que todos los modelos producen salidas válidas: forma correcta, sin NaNs y no negativas.
En resumen: El Paso 7 convierte todos los modelos candidatos en “piezas intercambiables” con una interfaz común, garantizando que el sistema completo pueda consumirlos de forma homogénea en los pasos siguientes (validación, ensembles, despliegue en la web). Es el punto en que la experimentación previa con estrategias y reglas se transforma en un módulo productivo, estable y utilizable dentro del flujo de predicciones de OPEX FM.
Bloque 1 - Constantes y utilidades generales¶
# ===============================================================
# Paso 7 · Interfaces de modelos (mínimo viable)
# Implementamos un módulo autocontenido con wrappers de forecasting
# y una firma común: fit_predict(model_spec, y_train, dates_train, horizon, ...)
# En todo momento priorizamos robustez, no negatividad y reproducibilidad.
# ===============================================================
# Fijamos semilla global para reproducibilidad de cualquier aleatoriedad interna.
np.random.seed(7)
# ===============================================================
# Bloque 1) Constantes y utilidades generales
# ===============================================================
# Definimos un pequeño número epsilon para evitar divisiones por cero.
_EPS = 1e-12
def _to_numpy_1d(y):
# Normalizamos a vector 1D float y saneamos NaNs/inf a 0.0
if y is None:
return np.zeros(0, dtype=float)
arr = np.asarray(y, dtype=float).reshape(-1,)
# Saneamos valores no finitos
mask = np.isfinite(arr)
if not mask.all():
arr = np.where(mask, arr, 0.0)
return arr
def _to_ms_dates(dates) -> np.ndarray:
"""Normalizamos las fechas a inicio de mes (MS) como pandas.Timestamp."""
if dates is None:
return np.array([], dtype="datetime64[ns]")
if isinstance(dates, (list, tuple, np.ndarray, pd.Series, pd.Index)):
ds = pd.to_datetime(pd.Series(dates)).dt.to_period("M").dt.to_timestamp()
return ds.values
# Si no podemos interpretar, devolvemos array vacío (el flujo downstream lo gestionará).
return np.array([], dtype="datetime64[ns]")
def _future_months(last_date: pd.Timestamp, horizon: int, freq: str = "MS") -> pd.DatetimeIndex:
"""Generamos los meses futuros desde last_date con frecuencia mensual."""
if horizon <= 0:
return pd.DatetimeIndex([], dtype="datetime64[ns]")
last_ms = pd.to_datetime(last_date).to_period("M").to_timestamp()
start = last_ms + pd.offsets.MonthBegin(1)
return pd.date_range(start, periods=horizon, freq=freq)
def _is_all_zero(y: np.ndarray) -> bool:
"""Determinamos si toda la serie es cero."""
return y.size > 0 and np.allclose(y, 0.0)
def _max_zero_run_length(y: np.ndarray) -> int:
"""Calculamos la racha máxima de ceros consecutivos en y."""
if y.size == 0:
return 0
is_zero = (y == 0.0)
if not np.any(is_zero):
return 0
# Contamos rachas consecutivas
max_run = run = 0
for v in is_zero:
if v:
run += 1
max_run = max(max_run, run)
else:
run = 0
return max_run
def _prop_zeros(y: np.ndarray) -> float:
"""Calculamos la proporción de ceros en la serie."""
if y.size == 0:
return 0.0
return float((y == 0.0).sum()) / float(y.size)
def _month_number_index(dates: np.ndarray) -> np.ndarray:
"""Extraemos el número de mes (1..12) para cada fecha."""
if dates.size == 0:
return np.array([], dtype=int)
return pd.to_datetime(dates).month.values
def _last_complete_year_map(y: np.ndarray, dates: np.ndarray) -> Dict[int, float]:
"""
Construimos un mapa mes->valor del último año completo disponible.
Si no tenemos 12 meses en el último año, probamos hacia atrás.
Si no encontramos ningún año completo, devolvemos dict vacío.
"""
if y.size == 0 or dates.size == 0:
return {}
df = pd.DataFrame({"FECHA": pd.to_datetime(dates), "y": y})
df["YEAR"] = df["FECHA"].dt.year
df["MONTH"] = df["FECHA"].dt.month
years = list(sorted(df["YEAR"].unique(), reverse=True))
for year in years:
sub = df[df["YEAR"] == year]
if sub["MONTH"].nunique() == 12:
return sub.set_index("MONTH")["y"].to_dict()
return {}
def _median_by_month(y: np.ndarray, dates: np.ndarray) -> Dict[int, float]:
"""Calculamos la mediana por mes del histórico."""
if y.size == 0 or dates.size == 0:
return {}
df = pd.DataFrame({"FECHA": pd.to_datetime(dates), "y": y})
df["MONTH"] = df["FECHA"].dt.month
med = df.groupby("MONTH")["y"].median().to_dict()
return med
Bloque 2 - Baselines (independientes de statsmodels)¶
# ===============================================================
# Bloque 2) Baselines (independientes de statsmodels)
# ===============================================================
def _baseline_cero(h: int) -> np.ndarray:
"""Pronóstico estructural cero."""
return np.zeros(h, dtype=float)
def _baseline_naive1(y: np.ndarray, h: int) -> np.ndarray:
"""Repetimos el último valor del histórico."""
if y.size == 0:
return np.zeros(h, dtype=float)
return np.repeat(float(y[-1]), h)
def _baseline_median_seasonal(y: np.ndarray, dates: np.ndarray, h: int) -> np.ndarray:
"""Proyectamos la mediana por mes del histórico."""
if y.size == 0 or dates.size == 0:
return np.zeros(h, dtype=float)
med = _median_by_month(y, dates)
last_date = pd.to_datetime(dates[-1])
future = _future_months(last_date, h, "MS")
out = np.zeros(h, dtype=float)
last = float(y[-1]) if y.size > 0 else 0.0
for i, d in enumerate(future):
out[i] = med.get(d.month, last)
return out
def _baseline_snaive12(y: np.ndarray, dates: np.ndarray, h: int) -> np.ndarray:
"""
SNaive12 robusto: usamos el mismo mes del último año completo disponible.
Si no hay año completo, caemos a mediana estacional; si falta algo, último valor.
"""
if h <= 0:
return np.zeros(0, dtype=float)
if y.size == 0 or dates.size == 0:
return np.zeros(h, dtype=float)
# Intentamos mapa del último año completo.
last_year_map = _last_complete_year_map(y, dates)
last_val = float(y[-1])
last_date = pd.to_datetime(dates[-1])
future = _future_months(last_date, h, "MS")
if len(last_year_map) == 12:
# Proyectamos con el mapa; si falta alguna clave rara, usamos last_val.
out = np.array([last_year_map.get(d.month, last_val) for d in future], dtype=float)
return out
# Sin año completo -> mediana estacional
med = _median_by_month(y, dates)
if len(med) > 0:
out = np.array([med.get(d.month, last_val) for d in future], dtype=float)
return out
# Último *fallback*: último valor
return np.repeat(last_val, h)
def _baseline_perfil_escaso_uniforme(y: np.ndarray, h: int) -> np.ndarray:
"""Promedio global replicado; si no hay datos, ceros."""
if y.size == 0:
return np.zeros(h, dtype=float)
mu = float(np.mean(y))
return np.repeat(mu, h)
Bloque 3 - Modelos clásicos (statsmodels si está; fallbacks si no)¶
# ===============================================================
# Bloque 3) Modelos clásicos (statsmodels si está; fallbacks si no)
# ===============================================================
def _theta_predict(y: np.ndarray, dates: np.ndarray, h: int) -> np.ndarray:
"""Theta(period=12); si falla o no existe, caemos a mediana estacional."""
if h <= 0:
return np.zeros(0, dtype=float)
if y.size == 0:
return np.zeros(h, dtype=float)
if ThetaModel is None:
warnings.warn("ThetaModel no disponible; usamos fallback median_seasonal.")
return _baseline_median_seasonal(y, dates, h)
try:
tm = ThetaModel(np.asarray(y, dtype=float), period=12)
res = tm.fit()
f = np.asarray(res.forecast(h), dtype=float)
return f
except Exception as e:
warnings.warn(f"ThetaModel falló ({e}); usamos fallback median_seasonal.")
return _baseline_median_seasonal(y, dates, h)
def _ets_auto_predict(y: np.ndarray, h: int) -> np.ndarray:
"""ETS aditivo estacional y tendencia amortiguada; fallback naive1."""
if h <= 0:
return np.zeros(0, dtype=float)
if y.size < 3 or ExponentialSmoothing is None:
if ExponentialSmoothing is None:
warnings.warn("ExponentialSmoothing no disponible; usamos fallback naive1.")
return _baseline_naive1(y, h)
try:
model = ExponentialSmoothing(
np.asarray(y, dtype=float),
trend="add",
seasonal="add",
seasonal_periods=12,
damped_trend=True,
initialization_method="estimated",
).fit(optimized=True)
f = np.asarray(model.forecast(h), dtype=float)
return f
except Exception as e:
warnings.warn(f"ETS falló ({e}); usamos fallback naive1.")
return _baseline_naive1(y, h)
def _holt_damped_predict(y: np.ndarray, h: int) -> np.ndarray:
"""Holt amortiguado sin estacionalidad; fallback naive1."""
if h <= 0:
return np.zeros(0, dtype=float)
if y.size < 3 or ExponentialSmoothing is None:
if ExponentialSmoothing is None:
warnings.warn("ExponentialSmoothing no disponible; usamos fallback naive1.")
return _baseline_naive1(y, h)
try:
model = ExponentialSmoothing(
np.asarray(y, dtype=float),
trend="add",
seasonal=None,
damped_trend=True,
initialization_method="estimated",
).fit(optimized=True)
f = np.asarray(model.forecast(h), dtype=float)
return f
except Exception as e:
warnings.warn(f"Holt falló ({e}); usamos fallback naive1.")
return _baseline_naive1(y, h)
def _arima_auto_light(y: np.ndarray, h: int) -> np.ndarray:
"""Grid ligero ARIMA (p,d,q)∈{0,1} sin estacionalidad; fallback naive1."""
if h <= 0:
return np.zeros(0, dtype=float)
if SARIMAX is None or y.size < 8:
if SARIMAX is None:
warnings.warn("SARIMAX no disponible; usamos fallback naive1.")
return _baseline_naive1(y, h)
y_ = np.asarray(y, dtype=float)
best_aic = np.inf
best_forecast = None
for p in [0, 1]:
for d in [0, 1]:
for q in [0, 1]:
try:
mod = SARIMAX(
y_, order=(p, d, q), seasonal_order=(0, 0, 0, 0),
trend="c", enforce_stationarity=False, enforce_invertibility=False
)
res = mod.fit(disp=False)
if res.aic < best_aic:
best_aic = res.aic
best_forecast = np.asarray(res.forecast(h), dtype=float)
except Exception:
continue
if best_forecast is None:
warnings.warn("ARIMA grid no encontró solución; usamos fallback naive1.")
return _baseline_naive1(y, h)
return best_forecast
Bloque 4 - Intermitentes (implementación directa)¶
# ===============================================================
# Bloque 4) Intermitentes (implementación directa)
# ===============================================================
def _croston_classic(y: np.ndarray, h: int, alpha: float = 0.1) -> np.ndarray:
"""
Implementamos Croston clásico: suavizamos tamaño y periodicidad, y proyectamos z/p.
"""
if h <= 0:
return np.zeros(0, dtype=float)
y = np.asarray(y, dtype=float)
n = y.size
if n == 0:
return np.zeros(h, dtype=float)
z, p, k = 0.0, 0.0, 0
first = True
for v in y:
k += 1
if v > 0:
if first:
z, p, first = v, float(k), False
else:
z = z + alpha * (v - z)
p = p + alpha * (k - p)
k = 0
f = (z / p) if p > 0 else 0.0
return np.repeat(float(f), h)
def _croston_sba(y: np.ndarray, h: int, alpha: float = 0.1) -> np.ndarray:
"""
Variación SBA: corrección de sesgo multiplicando por (1 - alpha/2).
"""
base = _croston_classic(y, h, alpha=alpha)
return base * (1.0 - alpha / 2.0)
def _tsb(y: np.ndarray, h: int, alpha: float = 0.2, beta: float = 0.1) -> np.ndarray:
"""
TSB: modelamos por separado probabilidad de demanda y tamaño medio de demanda.
"""
if h <= 0:
return np.zeros(0, dtype=float)
y = np.asarray(y, dtype=float)
if y.size == 0:
return np.zeros(h, dtype=float)
demand = (y > 0).astype(int)
sizes = np.where(y > 0, y, 0.0)
# Inicializamos con primer valor (o media de positivos si el primero es cero)
p = float(demand[0])
z = float(sizes[0] if sizes[0] > 0 else (np.mean(sizes[sizes > 0]) if np.any(sizes > 0) else 0.0))
for t in range(1, y.size):
p = p + alpha * (float(demand[t]) - p)
if y[t] > 0:
z = z + beta * (float(y[t]) - z)
f = p * z
return np.repeat(float(f), h)
Bloque 5 - ADIDA/MAPA (mínimo viable)¶
# ===============================================================
# Bloque 5) ADIDA/MAPA (mínimo viable)
# ===============================================================
def _adida_mapa(y: np.ndarray,
h: int,
low_freq_models: Optional[List[str]] = None) -> np.ndarray:
"""
Implementamos un ADIDA/MAPA mínimo:
1) Agregamos a trimestral (sumas de 3 meses; rellenamos con 0 para cerrar bloque).
2) Pronosticamos a baja frecuencia con ETS o Theta (fallback: último valor).
3) Desagregamos con perfil histórico medio por posición (mes1, mes2, mes3).
"""
if h <= 0:
return np.zeros(0, dtype=float)
y = np.asarray(y, dtype=float)
n = y.size
if n < 1:
return np.zeros(h, dtype=float)
# Agregación a trimestral (M3)
pad = (3 - (n % 3)) % 3
y_pad = np.hstack([y, np.zeros(pad, dtype=float)])
blocks = y_pad.reshape(-1, 3)
yQ = blocks.sum(axis=1)
# Modelo low frequency (priorizamos ETS; si no, Theta; si no, último valor)
if low_freq_models is None:
low_freq_models = ["ETS_auto", "Theta"]
fq_h = int(np.ceil(h / 3))
if fq_h <= 0:
return np.zeros(0, dtype=float)
def _lf_forecast(yQ_arr: np.ndarray, fq_h: int) -> np.ndarray:
# Intentamos ETS si está disponible y hay datos suficientes.
if "ETS_auto" in low_freq_models and ExponentialSmoothing is not None and yQ_arr.size >= 3:
try:
mQ = ExponentialSmoothing(yQ_arr, trend="add", seasonal=None, damped_trend=True).fit(optimized=True)
return np.asarray(mQ.forecast(fq_h), dtype=float)
except Exception:
pass
# Theta como alternativa si está disponible.
if "Theta" in low_freq_models and ThetaModel is not None and yQ_arr.size >= 1:
try:
tm = ThetaModel(yQ_arr) # periodo no crítico en trimestral sin estacionalidad fuerte
res = tm.fit()
return np.asarray(res.forecast(fq_h), dtype=float)
except Exception:
pass
# Último fallback: repetir el último trimestre observado (o ceros si vacío).
last_q = float(yQ_arr[-1]) if yQ_arr.size > 0 else 0.0
return np.repeat(last_q, fq_h)
fq = _lf_forecast(yQ, fq_h)
# Desagregación por perfil mensual medio dentro del trimestre
totals = blocks.sum(axis=1, keepdims=True)
# Evitamos divisiones por cero
weights = np.divide(blocks, np.where(totals == 0.0, 1.0, totals))
w = np.nan_to_num(weights.mean(axis=0))
if float(np.sum(w)) <= _EPS:
w = np.array([1/3, 1/3, 1/3], dtype=float)
# Expandimos cada trimestre pronosticado en 3 meses
monthly = []
for qv in fq:
monthly.extend(list(w * qv))
monthly = np.asarray(monthly, dtype=float)[:h]
return monthly
Bloque 6 - Router: mapeo por family/name + reglas de ruta¶
# ===============================================================
# Bloque 6) Router: mapeo por family/name + reglas de ruta
# ===============================================================
def _normalize_spec(spec: Dict[str, Any]) -> Dict[str, Any]:
"""Normalizamos el model_spec para trabajar con claves en minúscula y defaults."""
spec = dict(spec or {})
name = str(spec.get("name", "")).strip()
family = str(spec.get("family", "")).strip().lower()
params = dict(spec.get("params", {}) or {})
# Alias de nombre → family si no se indicó
lname = name.strip().lower()
aliases = {
"snaive12": ("baseline", {"type": "snaive12"}),
"naive1": ("baseline", {"type": "naive1"}),
"median_seasonal": ("baseline", {"type": "median_seasonal"}),
"medianaestac": ("baseline", {"type": "median_seasonal"}),
"cero": ("baseline", {"type": "cero"}),
"perfil_escaso_uniforme": ("baseline", {"type": "perfil_escaso_uniforme"}),
"ets_auto": ("ets", {}),
"ets": ("ets", {}),
"theta": ("theta", {}),
"arima_auto": ("arima", {"grid": "light"}),
"holt_damped": ("holt", {}),
"croston": ("croston", {"variant": "classic"}),
"sba": ("croston", {"variant": "sba"}),
"tsb": ("tsb", {}),
"adida_mapa": ("adida_mapa", {}),
"adida": ("adida_mapa", {}),
"mapa": ("adida_mapa", {}),
}
if not family and lname in aliases:
fam, default_p = aliases[lname]
family = fam
# Mezclamos defaults con los params existentes sin pisar lo que ya venga
for k, v in default_p.items():
params.setdefault(k, v)
# Defaults específicos
if family == "croston":
params.setdefault("variant", "classic") # classic | sba
params.setdefault("alpha", 0.1)
if family == "tsb":
params.setdefault("alpha", 0.2)
params.setdefault("beta", 0.1)
if family == "adida_mapa":
params.setdefault("agg_level", "Q")
params.setdefault("model_low_freq", ["ETS_auto", "Theta"])
params.setdefault("disagg", "perfil_historico")
if family == "baseline":
params.setdefault("type", "naive1")
spec["name"] = name
spec["family"] = family
spec["params"] = params
return spec
def _route_family_with_rules(family: str,
params: Dict[str, Any],
y: np.ndarray) -> Tuple[str, Dict[str, Any]]:
"""
Aplicamos reglas internas según intermitencia y fuerza:
- Si route="INTERMITENTE" y force=False:
* Evitamos modelos pesados (ets/arima/holt/theta) y vamos a TSB o Croston.
- ADIDA/MAPA restringido si prop_zeros ≥ 0.9 (preferimos TSB).
"""
route = str(params.get("route", "")).strip().upper()
force = bool(params.get("force", False))
pz = _prop_zeros(y)
mz = _max_zero_run_length(y)
# Regla de cero estructural viene en fit_predict; aquí no la duplicamos.
if route == "INTERMITENTE" and not force:
# Si se pidió un modelo pesado, lo redirigimos a TSB por defecto.
if family in {"ets", "arima", "holt", "theta"}:
new_family = "tsb"
new_params = dict(params)
warnings.warn("route=INTERMITENTE y force=False -> redirigimos a TSB.")
return new_family, new_params
# Si se pidió ADIDA/MAPA pero la intermitencia es extrema, redirigimos a TSB.
if family == "adida_mapa" and pz >= 0.90:
new_family = "tsb"
new_params = dict(params)
warnings.warn("INTERMITENTE con prop_zeros>=0.90 -> preferimos TSB sobre ADIDA/MAPA.")
return new_family, new_params
return family, params
Bloque 7 - API pública¶
# ===============================================================
# Bloque 7) API pública
# ===============================================================
def fit_predict(model_spec: Dict[str, Any],
y_train,
dates_train,
horizon: int,
freq: str = "MS",
exog: Optional[Dict[str, Any]] = None) -> np.ndarray:
"""
Devolvemos un np.ndarray de longitud = horizon, con no negatividad garantizada.
Aplicamos reglas internas:
- Cero estructural automático (36 ceros seguidos o todo-cero) -> baseline cero.
- route=INTERMITENTE y force=False -> preferimos TSB/Croston; limitamos ADIDA/MAPA en intermitentes extremas.
"""
# Normalizamos entradas
h = int(horizon)
if h <= 0:
return np.zeros(0, dtype=float)
y = _to_numpy_1d(y_train)
dates = _to_ms_dates(dates_train)
# Si no hay fechas, generamos un proxy equiespaciado mensual empezando en 2000-01
# para que las funciones que requieren fechas tengan algo razonable.
if dates.size == 0 and y.size > 0:
start = pd.Timestamp("2000-01-01")
dates = pd.date_range(start, periods=y.size, freq="MS").values
# Reglas de cero estructural
if _is_all_zero(y) or _max_zero_run_length(y) >= 36:
# Registramos la decisión con un warning suave para trazabilidad.
warnings.warn("Detectamos cero estructural (todo ceros o racha>=36); devolvemos baseline cero.")
return _baseline_cero(h)
# Normalizamos el spec y aplicamos reglas de ruta
spec = _normalize_spec(model_spec or {})
family = spec.get("family", "")
params = dict(spec.get("params", {}) or {})
family, params = _route_family_with_rules(family, params, y)
# Enrutamos por familia
try:
if family == "baseline":
btype = str(params.get("type", "naive1")).strip().lower()
if btype == "cero":
pred = _baseline_cero(h)
elif btype in {"naive1", "naive"}:
pred = _baseline_naive1(y, h)
elif btype in {"snaive12", "seasonal_naive", "snaive"}:
pred = _baseline_snaive12(y, dates, h)
elif btype in {"median_seasonal", "mediana_estacional", "medianaestac"}:
pred = _baseline_median_seasonal(y, dates, h)
elif btype in {"perfil_escaso_uniforme", "perfil_escaso"}:
pred = _baseline_perfil_escaso_uniforme(y, h)
else:
warnings.warn(f"Baseline desconocido '{btype}'; usamos naive1.")
pred = _baseline_naive1(y, h)
elif family == "theta":
pred = _theta_predict(y, dates, h)
elif family == "ets":
pred = _ets_auto_predict(y, h)
elif family == "holt":
pred = _holt_damped_predict(y, h)
elif family == "arima":
# Permitimos 'grid'='light' en params, aunque no lo usamos explícitamente aquí.
pred = _arima_auto_light(y, h)
elif family == "croston":
variant = str(params.get("variant", "classic")).strip().lower()
alpha = float(params.get("alpha", 0.1))
if variant == "sba":
pred = _croston_sba(y, h, alpha=alpha)
else:
pred = _croston_classic(y, h, alpha=alpha)
elif family == "tsb":
alpha = float(params.get("alpha", 0.2))
beta = float(params.get("beta", 0.1))
pred = _tsb(y, h, alpha=alpha, beta=beta)
elif family == "adida_mapa":
# Regla conservadora adicional aquí por seguridad
if _prop_zeros(y) >= 0.95:
warnings.warn("Intermitencia extrema (prop_zeros>=0.95); usamos TSB en lugar de ADIDA/MAPA.")
pred = _tsb(y, h, alpha=float(params.get("alpha", 0.2)), beta=float(params.get("beta", 0.1)))
else:
lf = params.get("model_low_freq") or ["ETS_auto", "Theta"]
pred = _adida_mapa(y, h, low_freq_models=list(lf))
else:
warnings.warn(f"Familia desconocida '{family}'; usamos baseline naive1.")
pred = _baseline_naive1(y, h)
except Exception as e:
# Si algo explota, caemos a un fallback por familia o naive1 como red de seguridad.
warnings.warn(f"Error en familia '{family}': {e}. Aplicamos fallback.")
if family == "theta":
pred = _baseline_median_seasonal(y, dates, h)
elif family in {"ets", "holt", "arima"}:
pred = _baseline_naive1(y, h)
elif family in {"croston", "tsb", "adida_mapa"}:
pred = _baseline_perfil_escaso_uniforme(y, h)
else:
pred = _baseline_naive1(y, h)
# Clipping de no negatividad como último paso.
pred = np.asarray(pred, dtype=float)
if pred.size != h:
# Si por alguna razón la longitud no cuadra, ajustamos de forma robusta.
if pred.size > h:
pred = pred[:h]
else:
pad = np.repeat(float(pred[-1]) if pred.size > 0 else 0.0, h - pred.size)
pred = np.hstack([pred, pad])
pred = np.maximum(pred, 0.0)
pred = np.where(np.isfinite(pred), pred, 0.0)
return pred
Bloque 8 - Tests ligeros (ejecución directa del módulo)¶
# ===============================================================
# Bloque 8) Tests ligeros (ejecución directa del módulo)
# ===============================================================
if __name__ == "__main__":
# Generamos datos sintéticos y verificamos forma, finitud y no negatividad.
# Comentamos en primera persona lo que comprobamos.
def _check(name: str, arr: np.ndarray, h: int):
ok_shape = (arr.shape == (h,))
ok_finite = np.all(np.isfinite(arr))
ok_nneg = np.all(arr >= 0.0)
print(f"[{name}] shape={arr.shape} finite={ok_finite} nneg={ok_nneg}")
if not (ok_shape and ok_finite and ok_nneg):
warnings.warn(f"El test '{name}' no cumple alguno de los checks básicos.")
# Parámetros comunes de test
H = 12
T = 60 # ~5 años de datos mensuales
dates = pd.date_range("2019-01-01", periods=T, freq="MS")
# 1) Estacional suave
rng = np.random.default_rng(7)
t = np.arange(T)
y_seasonal = 10.0 + 2.0 * np.sin(2 * np.pi * t / 12.0) + rng.normal(0, 0.3, size=T)
y_seasonal = np.maximum(y_seasonal, 0.0)
specs_seasonal = [
{"name": "Theta", "family": "theta", "params": {}},
{"name": "ETS_auto", "family": "ets", "params": {}},
{"name": "SNaive12", "family": "baseline", "params": {"type": "snaive12"}},
{"name": "MedianaEstac", "family": "baseline", "params": {"type": "median_seasonal"}},
]
print("\n== Test: Estacional suave ==")
for sp in specs_seasonal:
yhat = fit_predict(sp, y_seasonal, dates, H)
_check(sp["name"], yhat, H)
# 2) Intermitente (muchos ceros + picos)
y_inter = np.zeros(T, dtype=float)
# Simulamos picos aleatorios esporádicos
peak_idx = rng.choice(np.arange(T), size=int(T * 0.15), replace=False)
y_inter[peak_idx] = rng.uniform(1.0, 10.0, size=peak_idx.size)
specs_interm = [
{"name": "Croston", "family": "croston", "params": {"variant": "classic", "alpha": 0.1}},
{"name": "SBA", "family": "croston", "params": {"variant": "sba", "alpha": 0.1}},
{"name": "TSB", "family": "tsb", "params": {"alpha": 0.2, "beta": 0.1}},
{"name": "PerfilEscaso", "family": "baseline", "params": {"type": "perfil_escaso_uniforme"}},
]
print("\n== Test: Intermitente ==")
for sp in specs_interm:
yhat = fit_predict(sp, y_inter, dates, H)
_check(sp["name"], yhat, H)
# 2b) Enrutado sensible a ruta (INTERMITENTE redirigiendo ETS -> TSB)
sp_heavy_inter = {"name": "ETS_auto", "family": "ets", "params": {"route": "INTERMITENTE", "force": False}}
print("\n== Test: Enrutado route=INTERMITENTE (redirigimos ETS->TSB) ==")
yhat = fit_predict(sp_heavy_inter, y_inter, dates, H)
_check("ETS->TSB route", yhat, H)
# 3) Todo-cero (debería activar baseline cero si detectamos racha>=36)
y_zero = np.zeros(T, dtype=float)
specs_zero = [
{"name": "Cero", "family": "baseline", "params": {"type": "cero"}},
{"name": "SNaive12", "family": "baseline", "params": {"type": "snaive12"}},
{"name": "MedianaEstac", "family": "baseline", "params": {"type": "median_seasonal"}},
]
print("\n== Test: Todo-cero (activamos cero estructural) ==")
for sp in specs_zero:
yhat = fit_predict(sp, y_zero, dates, H)
_check(sp["name"], yhat, H)
# 4) ARIMA fallback (si SARIMAX no está, deberíamos caer a naive1 sin romper)
sp_arima = {"name": "ARIMA_auto", "family": "arima", "params": {"grid": "light"}}
print("\n== Test: ARIMA fallback ==")
yhat = fit_predict(sp_arima, y_seasonal, dates, H)
_check("ARIMA_auto", yhat, H)
# 5) ADIDA/MAPA en serie moderadamente intermitente
sp_adida = {"name": "ADIDA_MAPA", "family": "adida_mapa",
"params": {"agg_level": "Q", "model_low_freq": ["ETS_auto", "Theta"], "disagg": "perfil_historico"}}
print("\n== Test: ADIDA/MAPA ==")
yhat = fit_predict(sp_adida, y_inter, dates, H)
_check("ADIDA_MAPA", yhat, H)
# Interpretamos brevemente algunos outputs.
# - En estacional suave, esperamos que theta/ets se comporten razonablemente y que las baselines
# den resultados no negativos con forma correcta.
# - En intermitentes, Croston/TSB deben devolver niveles coherentes con la frecuencia de picos,
# y el enrutado de ETS a TSB cuando route=INTERMITENTE debe funcionar sin error.
# - En todo-cero, verificamos que detectamos cero estructural y devolvemos ceros de forma estable.
# - En ARIMA, confirmamos que si no hay dependencia, caemos a naive1 sin romper.
print("\n== Tests completados ==")
== Test: Estacional suave == [Theta] shape=(12,) finite=True nneg=True [ETS_auto] shape=(12,) finite=True nneg=True [SNaive12] shape=(12,) finite=True nneg=True [MedianaEstac] shape=(12,) finite=True nneg=True == Test: Intermitente == [Croston] shape=(12,) finite=True nneg=True [SBA] shape=(12,) finite=True nneg=True [TSB] shape=(12,) finite=True nneg=True [PerfilEscaso] shape=(12,) finite=True nneg=True == Test: Enrutado route=INTERMITENTE (redirigimos ETS->TSB) == [ETS->TSB route] shape=(12,) finite=True nneg=True == Test: Todo-cero (activamos cero estructural) == [Cero] shape=(12,) finite=True nneg=True [SNaive12] shape=(12,) finite=True nneg=True [MedianaEstac] shape=(12,) finite=True nneg=True == Test: ARIMA fallback == [ARIMA_auto] shape=(12,) finite=True nneg=True == Test: ADIDA/MAPA == [ADIDA_MAPA] shape=(12,) finite=True nneg=True == Tests completados ==
Interpretación Paso 7¶
Vemos que todos los tests pasan con forma (12,), finitos y no negativos. De acuerdo con lo que pedimos en el Paso 7, resumimos lo que significan estos resultados y qué pequeños afinados podemos añadir.
Interpretación rápida de los tests (en primera persona)
Estacional suave (Theta, ETS, SNaive12, MedianaEstac): confirmamos que los cuatro producen vectores válidos y ≥0. Interpretamos que la normalización de fechas y el clipping final están funcionando y que los fallbacks no se han activado en casos normales.
Intermitente (Croston, SBA, TSB, PerfilEscaso): todos devuelven predicciones estables y no negativas. Entendemos que las implementaciones directas de intermitentes están saneadas y listas para usarse en la ruta INTERMITENTE.
Enrutado route=INTERMITENTE (redirigimos ETS→TSB): verificamos que el router respeta la regla de negocio y evita modelos “pesados” cuando no se fuerza. Confirmamos que la lógica de params.route se aplica correctamente.
Todo-cero (cero estructural): al detectar 36 ceros (o toda la serie en cero) forzamos baseline cero y obtenemos salida limpia. Esto valida la gobernanza que venimos usando desde los pasos 3–6.
ARIMA fallback: aunque SARIMAX no esté o falle, devolvemos predicción válida (vía Naive1). Confirmamos que no rompemos en ausencia de dependencias.
ADIDA/MAPA: obtenemos salida válida con desagregación por perfil histórico y truncado adecuado al horizonte. Interpretamos que los pesos trimestrales y la generación de futuro están correctamente acoplados.
Paso 8 - Métricas finales sobre 2024¶
Bloque preliminar - inspección de columnas¶
Miramos columnas para definirlas de manera explicita en los bloques del paso 8.
# ============================================================
# ESTRATEGIA 3 — PASO 8: Métricas finales sobre 2024
# ============================================================
# === Paso 8 - Bloque preliminar: inspección de columnas ===
# Definimos rutas
PATH_TRAIN = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/train_full_2021_2023.csv"
PATH_TEST = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/test_full_2024.csv"
PATH_PREDS = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_por_serie_2024.csv"
# Leemos los ficheros sin forzar columnas
df_train_raw = pd.read_csv(PATH_TRAIN, sep=";")
df_test_raw = pd.read_csv(PATH_TEST, sep=";")
df_preds_raw = pd.read_csv(PATH_PREDS, sep=";")
# Imprimimos columnas para verificar
print("Columnas en TRAIN:", df_train_raw.columns.tolist())
print("Columnas en TEST:", df_test_raw.columns.tolist())
print("Columnas en PREDS:", df_preds_raw.columns.tolist())
Columnas en TRAIN: ['ID_BUILDING', 'FM_COST_TYPE', 'FECHA', 'cost_float_mod'] Columnas en TEST: ['ID_BUILDING', 'FM_COST_TYPE', 'FECHA', 'cost_float_mod'] Columnas en PREDS: ['ID_BUILDING', 'FM_COST_TYPE', 'FECHA', 'yhat_modelo1', 'yhat_modelo2', 'yhat_combo', 'fallback_flag', 'route', 'modelo_top1', 'modelo_top2', 'combo_rule']
# === Paso 8 - Bloque preliminar: inspección de columnas ===
# Definimos rutas
PATH_BASELINES = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_baselines_cv.csv"
# Leemos los ficheros sin forzar columnas
df_baselines_raw = pd.read_csv(PATH_BASELINES, sep=";")
# Imprimimos columnas para verificar
print("Columnas en baselines:", df_baselines_raw.columns.tolist())
df_baselines_raw.head()
Columnas en baselines: ['ID_BUILDING', 'FM_COST_TYPE', 'baseline', 'n_obs', 'MAE', 'WAPE', 'SMAPE', 'MASE1', 'MASE12']
ID_BUILDING | FM_COST_TYPE | baseline | n_obs | MAE | WAPE | SMAPE | MASE1 | MASE12 | |
---|---|---|---|---|---|---|---|---|---|
0 | 2 | Licencias | Naive1 | 12 | 16.9000 | 1.000000 | 16.666667 | 0.099118 | 0.067581 |
1 | 2 | Licencias | SNaive12 | 12 | 16.9000 | 1.000000 | 16.666667 | 0.099118 | 0.067581 |
2 | 2 | Licencias | MedianaEstacional | 12 | 21.5625 | 1.275888 | 33.333333 | 0.126463 | 0.086226 |
3 | 2 | Mtto. Contratos | Naive1 | 12 | 886.0650 | 3.078200 | 144.597430 | 1.185958 | 1.438559 |
4 | 2 | Mtto. Contratos | SNaive12 | 12 | 1247.1375 | 4.332570 | 153.242066 | 1.669237 | 2.024774 |
Bloque 1 - Constantes y parámetros¶
# ============================================================
# PASO 8 — Métricas finales 2024 (evaluación del modelo_final)
# Bloque 1) Constantes y parámetros
# ============================================================
# Fijamos semilla para reproducibilidad en cualquier componente que la use
np.random.seed(7)
# Definimos rutas base (reutilizamos si ya existen; si no, ponemos defaults seguros)
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
# Rutas canónicas de salida
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
RUTA_FIGS = os.path.join(ruta_base_3, "FIGS")
# Aseguramos carpetas de salida
for _d in [RUTA_RESULTADOS, RUTA_METRICAS, RUTA_LOGS, RUTA_REPORTING, RUTA_FIGS]:
os.makedirs(_d, exist_ok=True)
# Ficheros de entrada del paso
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv")
PATH_TEST = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv")
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv")
PATH_SEG = os.path.join(RUTA_METRICAS, "segmentation_intermitencia_step3.csv")
PATH_BASES = os.path.join(RUTA_METRICAS, "metrics_baselines_cv.csv") # para SNaive12_2024
PATH_FINAL = os.path.join(RUTA_METRICAS, "modelo_final_paso6.csv")
# Ficheros de salida del paso
OUT_PAIR = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv")
OUT_OVER = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_overall.csv")
OUT_VS_SNV = os.path.join(RUTA_REPORTING, "paso8_resumen_vs_snaive12_por_ruta.csv")
OUT_ALERTS = os.path.join(RUTA_REPORTING, "paso8_intermitentes_alertas.csv") # vistas para alertas
# Parámetros de claves y columnas
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
VALUE_TRAINTEST = "cost_float_mod" # columna de valor canónica
PRED_COL = "yhat_combo" # columna de predicción combinada del Paso 5
# Rango canónico de 2024 (malla MS)
FREQ = "MS"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq=FREQ)
# Epsilon para divisiones seguras y clips
EPS = 1e-8
# Log del paso 8
LOG_PATH_STEP8 = os.path.join(RUTA_LOGS, "estrategia3_step8.log")
def log8(msg: str):
"""Escribimos mensajes del Paso 8 en el log y en consola para trazabilidad."""
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP8, "a", encoding="utf-8") as f:
f.write(line + "\n")
Bloque 2 - Funciones auxiliares¶
# ============================================================
# PASO 8 — Bloque 2) Funciones auxiliares
# ============================================================
# -- Utilidades de fecha e IO --
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
"""Normalizamos la columna de fecha a mensual (MS)."""
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _read_csv(path: str, required: bool=True, sep: str=CSV_SEP) -> pd.DataFrame:
"""Leemos un CSV con el separador del proyecto; si es opcional y no existe, devolvemos vacío."""
if os.path.exists(path):
return pd.read_csv(path, sep=sep)
if required:
raise FileNotFoundError(f"Falta el archivo requerido: {path}")
log8(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _agg_dup_and_reindex(df: pd.DataFrame, datecol: str, valuecol: str,
start: str, end: str, pair_cols=PAIR_COLS, agg="sum") -> pd.DataFrame:
"""Agregamos duplicados por (pair, mes) y reindexamos a malla mensual rellenando huecos a 0."""
if df.empty:
return pd.DataFrame(columns=[*pair_cols, datecol, valuecol])
g = (df.groupby(pair_cols + [pd.Grouper(key=datecol, freq="MS")], as_index=False)[valuecol]
.agg(agg))
all_pairs = g[pair_cols].drop_duplicates()
months = pd.date_range(start, end, freq="MS")
out = []
for a, b in all_pairs.values:
sub = g[(g[pair_cols[0]]==a) & (g[pair_cols[1]]==b)].set_index(datecol)
sub = sub.reindex(months).rename_axis(datecol).reset_index()
sub[pair_cols[0]] = a; sub[pair_cols[1]] = b
sub[valuecol] = sub[valuecol].fillna(0.0)
out.append(sub)
return pd.concat(out, ignore_index=True)[[*pair_cols, datecol, valuecol]]
# -- Métricas (reutilizamos criterio del proyecto) --
def _safe_div(num, den, eps=EPS):
"""Implementamos división segura para evitar divisiones por cero."""
return num / (den + eps)
def _mae(y_true, y_pred):
"""Calculamos el MAE de forma directa."""
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(np.mean(np.abs(y_pred - y_true)))
def _wape_safe(y_true, y_pred, eps=EPS):
"""Calculamos el WAPE con denominador seguro sum(|y_true|)+eps."""
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.sum(np.abs(y_pred - y_true))
den = np.sum(np.abs(y_true))
return float(_safe_div(num, den, eps))
def _smape_safe(y_true, y_pred, eps=EPS):
"""Calculamos el SMAPE con denominador simétrico y eps para estabilidad (en %)."""
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = (np.abs(y_true) + np.abs(y_pred)) / 2.0
return float(np.mean(_safe_div(num, den, eps)) * 100.0)
def _scale_diff(series, lag):
"""Calculamos la escala como media de |y_t - y_{t-lag}| sobre train."""
arr = np.asarray(series, dtype=float)
if len(arr) <= lag:
return np.nan
diffs = np.abs(arr[lag:] - arr[:-lag])
if len(diffs) == 0:
return np.nan
return float(np.mean(diffs))
def mase_from_scale(errors, scale):
"""Calculamos el MASE a partir de un vector de errores y una escala precomputada."""
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
errors = np.asarray(errors, dtype=float)
return float(np.mean(np.abs(errors)) / float(scale))
def _eval_series(y_true, y_pred, escalas_train: dict):
"""Calculamos las métricas comunes con las escalas calculadas en train (lag 1 y 12)."""
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
err = y_pred - y_true
mae = _mae(y_true, y_pred)
wape = _wape_safe(y_true, y_pred)
smape = _smape_safe(y_true, y_pred)
mase1 = mase_from_scale(err, escalas_train.get("scale1"))
mase12 = mase_from_scale(err, escalas_train.get("scale12"))
return {"MAE": mae, "WAPE": wape, "SMAPE": smape, "MASE1": mase1, "MASE12": mase12}
# -- Ayudas de empaquetado y vistas --
def _source_model_from_preds(df_pair_preds: pd.DataFrame) -> str:
"""Inferimos el tipo de modelo aplicado en 2024 para una pareja a partir de las columnas de preds."""
# Priorizamos baseline_cero si aparece explícito
tops = set(str(x).strip().lower() for x in df_pair_preds.get("modelo_top1", pd.Series(dtype=object)).dropna().unique())
if "baseline_cero" in tops:
return "baseline_cero"
# Si todo el año es fallback de SNaive12, lo marcamos como fallback_snaive12
if "fallback_flag" in df_pair_preds.columns and "modelo_top1" in df_pair_preds.columns:
ff_all = int(df_pair_preds["fallback_flag"].fillna(0).astype(int).sum()) == len(df_pair_preds)
top1_snv = all(str(x).strip().lower()=="snaive12" for x in df_pair_preds["modelo_top1"].dropna().unique())
if ff_all and top1_snv:
return "fallback_snaive12"
# En el resto de casos consideramos que proviene del modelo_final (posiblemente combinado)
return "modelo_final"
def _two_views(y_true: np.ndarray, y_pred: np.ndarray):
"""Construimos máscaras para vistas all_months y positive_only."""
mask_all = np.ones_like(y_true, dtype=bool)
mask_pos = (y_true > 0)
return {"all_months": mask_all, "positive_only": mask_pos}
def _smape_clip(y_true, y_pred, eps=EPS):
"""SMAPE con denominador clippeado por eps a modo diagnóstico."""
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = np.maximum((np.abs(y_true) + np.abs(y_pred)) / 2.0, eps)
return float(np.mean(num / den) * 100.0)
def _percent(x):
"""Formateamos porcentaje de forma robusta."""
try:
return f"{100.0*float(x):.2f}%"
except Exception:
return "NA"
def _plot_hist(data: pd.Series, title: str, out_path: str):
"""Guardamos histograma simple si hay datos suficientes (opcional para diagnóstico)."""
try:
plt.figure()
data.dropna().astype(float).plot(kind="hist", bins=20)
plt.title(title)
plt.xlabel(title)
plt.ylabel("Frecuencia")
plt.tight_layout()
plt.savefig(out_path)
plt.close()
log8(f"Guardado histograma: {out_path}")
except Exception as e:
log8(f"AVISO: no fue posible guardar histograma {title}: {e}")
def _normalize_baselines_df(df: pd.DataFrame) -> pd.DataFrame:
"""
Normalizamos nombres de columnas de metrics_baselines_cv.csv a canónicos, de forma case-insensitive.
Intentamos quedarnos con: 'baseline', PAIR_COLS, 'SMAPE', 'MASE12'.
Si no existen, devolvemos NaN en esas métricas tras el rename.
"""
if df is None or df.empty:
return pd.DataFrame(columns=[*PAIR_COLS, "baseline", "SMAPE", "MASE12"])
cols = list(df.columns)
lut = {c.lower(): c for c in cols} # mapeo case-insensitive
# Mapeo a nombres canónicos
rename_map = {}
if "smape" in lut: rename_map[lut["smape"]] = "SMAPE"
if "mase12" in lut: rename_map[lut["mase12"]] = "MASE12"
if "baseline" in lut: rename_map[lut["baseline"]] = "baseline"
out = df.rename(columns=rename_map).copy()
# Garantizamos presencia de métricas y claves
for c in ["SMAPE", "MASE12"]:
if c not in out.columns:
out[c] = np.nan
if "baseline" not in out.columns:
out["baseline"] = np.nan
for k in PAIR_COLS:
if k not in out.columns:
out[k] = np.nan
# Tipado robusto y limpieza de baseline
out["SMAPE"] = pd.to_numeric(out["SMAPE"], errors="coerce")
out["MASE12"] = pd.to_numeric(out["MASE12"], errors="coerce")
out["baseline"] = out["baseline"].astype(str).str.strip()
return out
Bloque 3 - Ejecución¶
# ============================================================
# PASO 8 — Bloque 3) Ejecución
# ============================================================
# 1) Cargamos entradas y normalizamos fechas
train_df = _ensure_ms(_read_csv(PATH_TRAIN, required=True), DATECOL)
test_df = _ensure_ms(_read_csv(PATH_TEST, required=True), DATECOL)
preds_df = _ensure_ms(_read_csv(PATH_PREDS, required=True), DATECOL)
seg_df = _read_csv(PATH_SEG, required=True)
bases_df = _read_csv(PATH_BASES, required=False) # opcional pero recomendado
# 2) Alineamos y aseguramos estructura en 2024
# - test: agregamos duplicados por suma y reindexamos mensual a 2024
test_2024 = test_df.copy()
test_2024 = test_2024[[*PAIR_COLS, DATECOL, VALUE_TRAINTEST]]
test_2024 = _agg_dup_and_reindex(test_2024, DATECOL, VALUE_TRAINTEST,
start="2024-01-01", end="2024-12-01",
pair_cols=PAIR_COLS, agg="sum")
# - preds: por diseño ya venía 12 filas por pareja, pero robustecemos agregando por suma si hubiera duplicados
if PRED_COL not in preds_df.columns:
raise ValueError(f"Esperábamos la columna de predicción '{PRED_COL}' en {os.path.basename(PATH_PREDS)}.")
preds_2024 = preds_df.copy()
preds_2024 = preds_2024[[*PAIR_COLS, DATECOL, PRED_COL, "fallback_flag", "route", "modelo_top1", "modelo_top2", "combo_rule"] \
if "fallback_flag" in preds_df.columns else [*PAIR_COLS, DATECOL, PRED_COL, "route", "modelo_top1", "modelo_top2", "combo_rule"]]
preds_2024 = _ensure_ms(preds_2024, DATECOL)
preds_2024 = (preds_2024.groupby(PAIR_COLS + [DATECOL], as_index=False)
.agg({PRED_COL:"sum",
"fallback_flag":"max" if "fallback_flag" in preds_2024.columns else "size"}))
# - construimos diccionarios de ruta/flag_all_zero_train
seg_cols = [c for c in ["segment","route","flag_all_zero_train"] if c in seg_df.columns]
seg_view = seg_df[PAIR_COLS + seg_cols].drop_duplicates().copy()
# normalizamos a columna 'ruta'
if "route" in seg_view.columns:
seg_view = seg_view.rename(columns={"route":"ruta"})
elif "segment" in seg_view.columns:
seg_view = seg_view.rename(columns={"segment":"ruta"})
else:
seg_view["ruta"] = "GENERAL"
if "flag_all_zero_train" not in seg_view.columns:
seg_view["flag_all_zero_train"] = 0
# 3) Panel de parejas a evaluar (restringimos a modelo_final del Paso 6)
final_df = _read_csv(PATH_FINAL, required=True) # contiene al menos PAIR_COLS
final_pairs = (final_df[PAIR_COLS]
.drop_duplicates()
.copy())
# Normalizamos claves por robustez (evitamos falsos duplicados por espacios)
def _norm_pair_keys(df):
out = df.copy()
if out.empty:
return out
# ID_BUILDING a int si es posible
try:
out[PAIR_COLS[0]] = pd.to_numeric(out[PAIR_COLS[0]], errors="coerce").astype("Int64")
except Exception:
pass
# FM_COST_TYPE strip de espacios
if out[PAIR_COLS[1]].dtype == object:
out[PAIR_COLS[1]] = out[PAIR_COLS[1]].astype(str).str.strip()
return out
final_pairs = _norm_pair_keys(final_pairs)
test_2024 = _norm_pair_keys(test_2024)
preds_2024 = _norm_pair_keys(preds_2024)
seg_view = _norm_pair_keys(seg_view)
# Nos quedamos exclusivamente con las parejas del modelo_final
pairs_panel = (final_pairs
.merge(seg_view, on=PAIR_COLS, how="left")
.fillna({"flag_all_zero_train": 0, "ruta": "GENERAL"}))
# Diagnóstico: cuántas de esas parejas tienen test 2024 y pred 2024
pairs_test = test_2024[PAIR_COLS].drop_duplicates()
pairs_pred = preds_2024[PAIR_COLS].drop_duplicates()
missing_test = (final_pairs
.merge(pairs_test, on=PAIR_COLS, how="left", indicator=True)
.query("_merge == 'left_only'")[PAIR_COLS])
missing_pred = (final_pairs
.merge(pairs_pred, on=PAIR_COLS, how="left", indicator=True)
.query("_merge == 'left_only'")[PAIR_COLS])
n_pairs_expected = pairs_panel.shape[0]
log8(f"Punto G — total de parejas candidatas a evaluar (modelo_final): {n_pairs_expected}")
if len(missing_test) > 0:
log8(f"AVISO: parejas del modelo_final sin test_2024: {len(missing_test)}. Ejemplos: {missing_test.head(10).values.tolist()}")
if len(missing_pred) > 0:
log8(f"AVISO: parejas del modelo_final sin preds_2024: {len(missing_pred)}. Ejemplos: {missing_pred.head(10).values.tolist()}")
# Índices para acceso rápido (se mantienen igual)
train_idx = train_df.set_index(PAIR_COLS)
test_idx = test_2024.set_index(PAIR_COLS)
pred_idx = preds_2024.set_index(PAIR_COLS)
# 4) Preparamos índices para acceso rápido
train_idx = train_df.set_index(PAIR_COLS)
test_idx = test_2024.set_index(PAIR_COLS)
pred_idx = preds_2024.set_index(PAIR_COLS)
# 5) Métricas por pareja (dos vistas)
rows = []
n_scale_zero = 0
n_alertas = 0
for bid, ctype, ruta, flag_zero in pairs_panel[[*PAIR_COLS, "ruta", "flag_all_zero_train"]].itertuples(index=False):
# Extraemos histórico 2021–2023
try:
tr_sub = train_idx.loc[(bid, ctype)].reset_index()
except KeyError:
tr_sub = pd.DataFrame(columns=[*PAIR_COLS, DATECOL, VALUE_TRAINTEST])
tr_sub = tr_sub.sort_values(DATECOL)
y_train = tr_sub[VALUE_TRAINTEST].astype(float).values if VALUE_TRAINTEST in tr_sub.columns else np.array([], dtype=float)
# Escalas MASE con lag 1 y 12 sobre train
scale1 = _scale_diff(y_train, 1)
scale12 = _scale_diff(y_train, 12)
scale_zero = int((pd.isna(scale1) or scale1==0) or (pd.isna(scale12) or scale12==0))
if scale_zero == 1:
n_scale_zero += 1
escalas = {"scale1": (np.nan if scale_zero else scale1),
"scale12": (np.nan if scale_zero else scale12)}
# Extraemos verdad-terreno 2024 y predicciones 2024
try:
te_sub = test_idx.loc[(bid, ctype)].reset_index()
except KeyError:
te_sub = pd.DataFrame(columns=[*PAIR_COLS, DATECOL, VALUE_TRAINTEST])
te_sub = te_sub.set_index(DATECOL).reindex(MONTHS_2024).reset_index().rename(columns={"index":DATECOL})
y_true = te_sub[VALUE_TRAINTEST].astype(float).values if VALUE_TRAINTEST in te_sub.columns else np.zeros(H, dtype=float)
try:
pr_sub = pred_idx.loc[(bid, ctype)].reset_index()
except KeyError:
pr_sub = pd.DataFrame(columns=[*PAIR_COLS, DATECOL, PRED_COL, "fallback_flag"])
pr_sub = _ensure_ms(pr_sub, DATECOL).set_index(DATECOL).reindex(MONTHS_2024).reset_index().rename(columns={"index":DATECOL})
y_pred = pr_sub[PRED_COL].astype(float).fillna(0.0).values if PRED_COL in pr_sub.columns else np.zeros(H, dtype=float)
# Determinamos metadata del modelo fuente a nivel pareja
# (reconstruimos desde el conjunto de predicciones anual)
try:
full_preds_pair = preds_df[(preds_df[PAIR_COLS[0]]==bid) & (preds_df[PAIR_COLS[1]]==ctype)].copy()
except Exception:
full_preds_pair = pd.DataFrame(columns=preds_df.columns)
source_model = _source_model_from_preds(full_preds_pair)
# Señal de alerta: cero estructural en train y actividad en 2024
alerta_actividad = int((int(flag_zero)==1) and np.any(y_true > 0))
if alerta_actividad == 1:
n_alertas += 1
# Construimos vistas y calculamos métricas
views = _two_views(y_true, y_pred)
# Guardamos FECHA_MIN y FECHA_MAX para la cobertura efectiva del 2024 observado
if np.all(np.isnan(y_true)):
fecha_min, fecha_max = (pd.NaT, pd.NaT)
else:
idx_valid = np.where(~np.isnan(y_true))[0]
fecha_min = MONTHS_2024[idx_valid[0]] if idx_valid.size>0 else pd.NaT
fecha_max = MONTHS_2024[idx_valid[-1]] if idx_valid.size>0 else pd.NaT
for vista, mask in views.items():
# Filtramos por máscara y también por no-NaN en verdad-terreno
mask_eff = mask & (~np.isnan(y_true))
y_t = y_true[mask_eff]
y_p = y_pred[mask_eff]
n_obs = int(mask_eff.sum())
n_obs_pos = int(np.sum(y_t > 0))
if n_obs == 0:
# Si no hay observaciones válidas, registramos NaNs en métricas
rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA_MIN": fecha_min, "FECHA_MAX": fecha_max,
"vista": vista, "ruta": ruta, "flag_all_zero_train": int(flag_zero),
"source_model": source_model,
"n_obs": 0, "n_obs_pos": 0,
"scale1": scale1, "scale12": scale12, "scale_zero": scale_zero,
"MAE": np.nan, "WAPE": np.nan, "SMAPE": np.nan, "MASE1": np.nan, "MASE12": np.nan,
# Diagnóstico opcional
"WAPE_micro": np.nan, "SMAPE_clip": np.nan,
# Comparativa (rellenaremos después)
"MASE12_SNaive12": np.nan, "SMAPE_SNaive12": np.nan,
"delta_MASE12_vs_SNaive12": np.nan, "delta_SMAPE_vs_SNaive12": np.nan,
# Info opcional del combo si existiera
"combo_rule": full_preds_pair["combo_rule"].dropna().unique()[0] if "combo_rule" in full_preds_pair.columns and full_preds_pair["combo_rule"].notna().any() else None,
"modelo_top1": full_preds_pair["modelo_top1"].dropna().unique()[0] if "modelo_top1" in full_preds_pair.columns and full_preds_pair["modelo_top1"].notna().any() else None,
"modelo_top2": full_preds_pair["modelo_top2"].dropna().unique()[0] if "modelo_top2" in full_preds_pair.columns and full_preds_pair["modelo_top2"].notna().any() else None,
"alerta_actividad_2024": alerta_actividad
})
continue
# Calculamos métricas oficiales para la vista
metrics = _eval_series(y_t, y_p, escalas)
# Diagnóstico opcional (no sustituye oficiales)
WAPE_micro = _wape_safe(y_t, y_p)
SMAPE_clip = _smape_clip(y_t, y_p, eps=EPS)
rows.append({
PAIR_COLS[0]: bid, PAIR_COLS[1]: ctype, "FECHA_MIN": fecha_min, "FECHA_MAX": fecha_max,
"vista": vista, "ruta": ruta, "flag_all_zero_train": int(flag_zero),
"source_model": source_model,
"n_obs": n_obs, "n_obs_pos": n_obs_pos,
"scale1": scale1, "scale12": scale12, "scale_zero": scale_zero,
**metrics,
"WAPE_micro": WAPE_micro, "SMAPE_clip": SMAPE_clip,
# Comparativa (rellenaremos después)
"MASE12_SNaive12": np.nan, "SMAPE_SNaive12": np.nan,
"delta_MASE12_vs_SNaive12": np.nan, "delta_SMAPE_vs_SNaive12": np.nan,
# Info opcional de combinación
"combo_rule": full_preds_pair["combo_rule"].dropna().unique()[0] if "combo_rule" in full_preds_pair.columns and full_preds_pair["combo_rule"].notna().any() else None,
"modelo_top1": full_preds_pair["modelo_top1"].dropna().unique()[0] if "modelo_top1" in full_preds_pair.columns and full_preds_pair["modelo_top1"].notna().any() else None,
"modelo_top2": full_preds_pair["modelo_top2"].dropna().unique()[0] if "modelo_top2" in full_preds_pair.columns and full_preds_pair["modelo_top2"].notna().any() else None,
"alerta_actividad_2024": alerta_actividad
})
# 6) Construimos DataFrame por pareja y persistimos (aún sin comparativa vs SNaive12)
pair_df = pd.DataFrame(rows)
# Trazabilidad básica
n_pairs_eval = pair_df[PAIR_COLS].drop_duplicates().shape[0]
log8(f"Punto H — parejas evaluadas: {n_pairs_eval} (esperado ~ 2.429)")
log8(f"Punto H — casos scale_zero=1: {n_scale_zero}")
log8(f"Punto H — nº alertas cero→actividad_2024: {n_alertas}")
# 7) Comparativa contra SNaive12 (si tenemos el artefacto)
if not bases_df.empty:
# 7.1 Normalizamos el CSV de baselines (case-insensitive y columnas canónicas)
bases_norm = _normalize_baselines_df(bases_df)
# 7.2 Normalizamos claves para evitar falsos no-joins por tipos/espacios
bases_norm = _norm_pair_keys(bases_norm)
# 7.3 Filtramos SNaive12 por pareja y nos quedamos con SMAPE y MASE12 de 2024
snaive = bases_norm[bases_norm["baseline"].str.lower() == "snaive12"][
PAIR_COLS + ["SMAPE", "MASE12"]
].rename(columns={"SMAPE": "SMAPE_SNaive12", "MASE12": "MASE12_SNaive12"})
# 7.4 Unimos con la vista all_months (comparativa oficial)
mask_all = (pair_df["vista"] == "all_months")
# Antes de merge: eliminamos en el lado izquierdo las columnas de comparativa si existen
cols_cmp = ["MASE12_SNaive12", "SMAPE_SNaive12",
"delta_MASE12_vs_SNaive12", "delta_SMAPE_vs_SNaive12"]
left_all = pair_df[mask_all].drop(columns=cols_cmp, errors="ignore").copy()
pair_df_all = left_all.merge(snaive, on=PAIR_COLS, how="left")
# 7.5 Deltas (final - snaive12) con coerción segura
pair_df_all["delta_MASE12_vs_SNaive12"] = (
pd.to_numeric(pair_df_all["MASE12"], errors="coerce")
- pd.to_numeric(pair_df_all["MASE12_SNaive12"], errors="coerce")
)
pair_df_all["delta_SMAPE_vs_SNaive12"] = (
pd.to_numeric(pair_df_all["SMAPE"], errors="coerce")
- pd.to_numeric(pair_df_all["SMAPE_SNaive12"], errors="coerce")
)
# 7.6 Volcamos comparativa de vuelta a pair_df (solo columnas necesarias)
pair_df = pair_df.merge(
pair_df_all[PAIR_COLS + ["MASE12_SNaive12", "SMAPE_SNaive12",
"delta_MASE12_vs_SNaive12", "delta_SMAPE_vs_SNaive12"]],
on=PAIR_COLS, how="left"
)
# 7.7 Resumen por ruta (solo all_months)
cmp_df = pair_df_all.copy()
cmp_df["mejora_MASE12"] = (
pd.to_numeric(cmp_df["MASE12"], errors="coerce")
< pd.to_numeric(cmp_df["MASE12_SNaive12"], errors="coerce")
).astype(int)
cmp_df["mejora_SMAPE"] = (
pd.to_numeric(cmp_df["SMAPE"], errors="coerce")
< pd.to_numeric(cmp_df["SMAPE_SNaive12"], errors="coerce")
).astype(int)
resumen_vs = (cmp_df.groupby("ruta")
.apply(lambda g: pd.Series({
"n_pairs": g[PAIR_COLS].drop_duplicates().shape[0],
"pct_mejora_MASE12": g["mejora_MASE12"].mean(),
"pct_mejora_SMAPE": g["mejora_SMAPE"].mean(),
"delta_MASE12_medio": g["delta_MASE12_vs_SNaive12"].mean(),
"delta_SMAPE_medio": g["delta_SMAPE_vs_SNaive12"].mean(),
}))
.reset_index())
resumen_vs["pct_mejora_MASE12_fmt"] = resumen_vs["pct_mejora_MASE12"].map(_percent)
resumen_vs["pct_mejora_SMAPE_fmt"] = resumen_vs["pct_mejora_SMAPE"].map(_percent)
resumen_vs.to_csv(OUT_VS_SNV, sep=CSV_SEP, index=False)
log8(f"Guardado: {OUT_VS_SNV}")
else:
log8("AVISO: no disponemos de metrics_baselines_cv.csv; omitimos comparativa vs SNaive12.")
# 7.8 Red de seguridad: garantizamos columnas de comparativa antes de ordenar/guardar
for c in ["MASE12_SNaive12", "SMAPE_SNaive12",
"delta_MASE12_vs_SNaive12", "delta_SMAPE_vs_SNaive12"]:
if c not in pair_df.columns:
pair_df[c] = np.nan
# 8) Persistimos tabla por pareja (dos vistas)
# Ordenamos columnas claves -> métricas -> comparativa
col_order = [
*PAIR_COLS, "FECHA_MIN", "FECHA_MAX", "vista", "ruta", "flag_all_zero_train", "source_model",
"n_obs", "n_obs_pos", "scale1", "scale12", "scale_zero",
"MAE", "WAPE", "SMAPE", "MASE1", "MASE12",
"WAPE_micro", "SMAPE_clip",
"MASE12_SNaive12", "SMAPE_SNaive12", "delta_MASE12_vs_SNaive12", "delta_SMAPE_vs_SNaive12",
"combo_rule", "modelo_top1", "modelo_top2", "alerta_actividad_2024"
]
# Añadimos cualquier columna faltante como NaN para guardar orden estable
for c in col_order:
if c not in pair_df.columns:
pair_df[c] = np.nan
pair_df = pair_df[col_order].sort_values(PAIR_COLS + ["vista"]).reset_index(drop=True)
pair_df.to_csv(OUT_PAIR, sep=CSV_SEP, index=False)
log8(f"Guardado: {OUT_PAIR} con {len(pair_df)} filas.")
# 9) Reporting agregado — overall (global, por ruta, por tipo_modelo)
# Derivamos 'tipo_modelo' a partir de source_model
def _tipo_from_source(s: str) -> str:
s = str(s or "").strip().lower()
if s == "baseline_cero":
return "baseline_cero"
if s == "fallback_snaive12":
return "fallback_snaive12"
return "modelo_final"
pair_df["tipo_modelo"] = pair_df["source_model"].map(_tipo_from_source)
def _agg_metrics(df):
return pd.Series({
"n_pairs": df[PAIR_COLS].drop_duplicates().shape[0],
"MAE_mean": df["MAE"].mean(), "MAE_median": df["MAE"].median(),
"WAPE_mean": df["WAPE"].mean(), "WAPE_median": df["WAPE"].median(),
"SMAPE_mean": df["SMAPE"].mean(), "SMAPE_median": df["SMAPE"].median(),
"MASE1_mean": df["MASE1"].mean(), "MASE1_median": df["MASE1"].median(),
"MASE12_mean": df["MASE12"].mean(),"MASE12_median": df["MASE12"].median(),
"n_obs_mean": df["n_obs"].mean(), "n_obs_pos_mean": df["n_obs_pos"].mean()
})
# Solo usamos vista all_months para el agregado oficial
pair_all = pair_df[pair_df["vista"]=="all_months"].copy()
overall_global = _agg_metrics(pair_all).to_frame().T.assign(n_rows=len(pair_all), scope="GLOBAL")
overall_by_route = (pair_all.groupby("ruta").apply(_agg_metrics).reset_index().assign(scope="POR_RUTA"))
overall_by_tipo = (pair_all.groupby("tipo_modelo").apply(_agg_metrics).reset_index().assign(scope="POR_TIPO"))
overall_out = pd.concat([overall_global, overall_by_route, overall_by_tipo], ignore_index=True)
overall_out.to_csv(OUT_OVER, sep=CSV_SEP, index=False)
log8(f"Guardado: {OUT_OVER}")
# 10) Reporte de intermitentes con alertas (flag_all_zero_train=1 y alerta_actividad_2024=1)
alerts = pair_df[(pair_df["flag_all_zero_train"]==1) & (pair_df["alerta_actividad_2024"]==1)].copy()
if not alerts.empty:
# Dejamos doble vista de métricas (all_months / positive_only)
alerts = alerts.sort_values(PAIR_COLS + ["vista"]).reset_index(drop=True)
alerts.to_csv(OUT_ALERTS, sep=CSV_SEP, index=False)
log8(f"Guardado: {OUT_ALERTS} con {alerts[PAIR_COLS].drop_duplicates().shape[0]} parejas.")
else:
log8("No hay parejas con alerta_actividad_2024=1 (cero estructural en train con actividad en 2024).")
# 11) Logs de trazabilidad y puntos de control
# - Distribución por ruta y flag_all_zero_train
dist_ruta = (pair_all.groupby("ruta")[PAIR_COLS]
.apply(lambda d: d.drop_duplicates().shape[0])
.rename("n_pairs").reset_index())
dist_flag = (pair_all.groupby("flag_all_zero_train")[PAIR_COLS]
.apply(lambda d: d.drop_duplicates().shape[0])
.rename("n_pairs").reset_index())
log8(f"Distribución por ruta (n_pairs): {dist_ruta.to_dict(orient='records')}")
log8(f"Distribución por flag_all_zero_train (n_pairs): {dist_flag.to_dict(orient='records')}")
# - Histogramas (opcionales) de n_obs y n_obs_pos
_plot_hist(pair_all["n_obs"], "n_obs", os.path.join(RUTA_FIGS, "paso8_hist_n_obs.png"))
_plot_hist(pair_all["n_obs_pos"], "n_obs_pos", os.path.join(RUTA_FIGS, "paso8_hist_n_obs_pos.png"))
# - % de mejoras vs SNaive12 por ruta (si hay comparativa)
if "MASE12_SNaive12" in pair_all.columns and pair_all["MASE12_SNaive12"].notna().any():
tmp = pair_all.dropna(subset=["MASE12","MASE12_SNaive12"]).copy()
tmp["win_MASE12"] = (tmp["MASE12"] < tmp["MASE12_SNaive12"]).astype(int)
pct_by_route = (tmp.groupby("ruta")["win_MASE12"].mean().rename("pct_win_MASE12").reset_index())
log8("Porcentaje de mejoras vs SNaive12 (MASE12) por ruta: " + pct_by_route.to_dict(orient="records").__str__())
if "SMAPE_SNaive12" in pair_all.columns and pair_all["SMAPE_SNaive12"].notna().any():
tmp2 = pair_all.dropna(subset=["SMAPE","SMAPE_SNaive12"]).copy()
tmp2["win_SMAPE"] = (tmp2["SMAPE"] < tmp2["SMAPE_SNaive12"]).astype(int)
pct_by_route_smape = (tmp2.groupby("ruta")["win_SMAPE"].mean().rename("pct_win_SMAPE").reset_index())
log8("Porcentaje de mejoras vs SNaive12 (SMAPE) por ruta: " + pct_by_route_smape.to_dict(orient="records").__str__())
# - Pairs con n_obs != 12
bad_cov = pair_all[pair_all["n_obs"] != 12][PAIR_COLS].drop_duplicates()
log8(f"Parejas con n_obs != 12: {bad_cov.shape[0]}")
if bad_cov.shape[0] > 0:
ejemplos = bad_cov.head(10).values.tolist()
log8(f"Ejemplos (hasta 10): {ejemplos}")
# - Top-10 peores por MASE12 (inspección rápida)
top_bad = pair_all.sort_values("MASE12", ascending=False)[PAIR_COLS + ["MASE12"]].head(10)
log8("Top-10 peores MASE12 (vista all_months): " + top_bad.to_dict(orient="records").__str__())
log8("Paso 8 finalizado.")
[2025-09-23T15:52:11] Punto G — total de parejas candidatas a evaluar (modelo_final): 2429 [2025-09-23T15:52:11] AVISO: parejas del modelo_final sin test_2024: 198. Ejemplos: [[2, 'Eficiencia Energética'], [57, 'Eficiencia Energética'], [57, 'Licencias'], [57, 'Mtto. Correctivo'], [57, 'Servicios Ctto.'], [57, 'Servicios Extra'], [57, 'Suministros'], [59, 'Licencias'], [59, 'Mtto. Correctivo'], [59, 'Obras']] [2025-09-23T15:53:26] Punto H — parejas evaluadas: 2429 (esperado ~ 2.429) [2025-09-23T15:53:26] Punto H — casos scale_zero=1: 106 [2025-09-23T15:53:27] Punto H — nº alertas cero→actividad_2024: 35 [2025-09-23T15:53:27] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/paso8_resumen_vs_snaive12_por_ruta.csv [2025-09-23T15:53:28] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/paso8_metrics_2024_por_pareja.csv con 4858 filas. [2025-09-23T15:53:28] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/paso8_metrics_2024_overall.csv [2025-09-23T15:53:28] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/paso8_intermitentes_alertas.csv con 35 parejas. [2025-09-23T15:53:28] Distribución por ruta (n_pairs): [{'ruta': 'GENERAL', 'n_pairs': 1032}, {'ruta': 'INTERMITENTE', 'n_pairs': 1397}] [2025-09-23T15:53:28] Distribución por flag_all_zero_train (n_pairs): [{'flag_all_zero_train': 0, 'n_pairs': 2325}, {'flag_all_zero_train': 1, 'n_pairs': 104}] [2025-09-23T15:53:29] Guardado histograma: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/FIGS/paso8_hist_n_obs.png [2025-09-23T15:53:30] Guardado histograma: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/FIGS/paso8_hist_n_obs_pos.png [2025-09-23T15:53:30] Parejas con n_obs != 12: 198 [2025-09-23T15:53:30] Ejemplos (hasta 10): [[2, 'Eficiencia Energética'], [57, 'Eficiencia Energética'], [57, 'Licencias'], [57, 'Mtto. Correctivo'], [57, 'Servicios Ctto.'], [57, 'Servicios Extra'], [57, 'Suministros'], [59, 'Licencias'], [59, 'Mtto. Correctivo'], [59, 'Obras']] [2025-09-23T15:53:30] Top-10 peores MASE12 (vista all_months): [{'ID_BUILDING': 1001155, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 797.9007421234954}, {'ID_BUILDING': 1001156, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 712.7656069883597}, {'ID_BUILDING': 1001154, 'FM_COST_TYPE': 'Servicios Extra', 'MASE12': 703.7574615284893}, {'ID_BUILDING': 1001135, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 583.1854774654578}, {'ID_BUILDING': 1001154, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 519.3349754676485}, {'ID_BUILDING': 1000270, 'FM_COST_TYPE': 'Servicios Extra', 'MASE12': 313.793202614379}, {'ID_BUILDING': 1000607, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 195.7336820334289}, {'ID_BUILDING': 617, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 166.6442921512004}, {'ID_BUILDING': 1000574, 'FM_COST_TYPE': 'Servicios Ctto.', 'MASE12': 165.89353993751413}, {'ID_BUILDING': 257, 'FM_COST_TYPE': 'Servicios Extra', 'MASE12': 132.52504332427952}] [2025-09-23T15:53:30] Paso 8 finalizado.
Interpretación paso 8¶
- Observamos y confirmamos que:
2429 parejas evaluadas (coincide con lo esperado).
198 parejas sin test_2024 → por eso aparecen los avisos y n_obs != 12 en el log.
106 casos con escala cero y 35 alertas de actividad en 2024 pese a cero estructural en train.
- Archivos generados correctamente:
paso8_metrics_2024_por_pareja.csv con 4858 filas (2429 parejas × 2 vistas).
paso8_metrics_2024_overall.csv (medias y medianas globales, por ruta y por tipo).
paso8_resumen_vs_snaive12_por_ruta.csv (comparativa frente a SNaive12).
paso8_intermitentes_alertas.csv (listado de las 35 alertas).
- Distribuciones por ruta y flag_all_zero_train cuadran con lo detectado en pasos anteriores.
Bloque Revisión Preliminar - Revisión de metricas obtenidas y bonad de las predicciones¶
Llegados al Paso 8, ya tenemos todos los ingredientes para empezar a sacar conclusiones sobre el desempeño real del modelo masivo.
Lo que vemos ahora mismo:
Cobertura
Se han evaluado 2.429 parejas, que son las previstas en el modelo_final_paso6.csv.
De ellas, 198 no tienen test_2024, lo que implica que no podemos comprobar predicciones contra verdad-terreno en esos casos. Esto ya nos marca un límite: el modelo no puede validarse en todas las parejas, solo en unas 2.231.
Casos especiales
106 parejas con escala cero → el MASE no es evaluable ahí, pero sí tenemos SMAPE/WAPE.
35 alertas de actividad → en estas series el modelo venía de un cero estructural (train sin gasto) y en 2024 aparece gasto. Aquí se pone a prueba la capacidad del modelo de detectar reactivaciones.
Calidad global del modelo
El archivo paso8_resumen_vs_snaive12_por_ruta.csv ya nos dice, por ruta, qué % de parejas mejora frente al baseline SNaive12 en MASE12 y en SMAPE.
Este es el primer termómetro serio: si el % de mejora está por encima del 50%, podemos afirmar que el modelo aporta valor frente al baseline trivial; si está por debajo, el modelo no generaliza bien.
Los deltas medios (delta_MASE12_medio, delta_SMAPE_medio) nos dicen si el modelo reduce o aumenta el error respecto a SNaive12 en promedio.
Diagnóstico adicional
En paso8_metrics_2024_por_pareja.csv podemos filtrar por vista=="all_months" y ver distribuciones de SMAPE y MASE12. Si la mediana está razonablemente baja (ej. SMAPE < 20–25%), podemos decir que el modelo tiene buen desempeño medio.
El overall_out.csv ya tiene esas medias/medianas, segmentadas por ruta (GENERAL vs INTERMITENTE) y por tipo de modelo (modelo_final, fallback_snaive12, baseline_cero).
Si las medianas del modelo_final son significativamente mejores que las de los fallback, tenemos evidencia de que la lógica de selección/ensamble funcionó.
En resumen: A este punto sí se pueden sacar conclusiones, pero requieren mirar los tres outputs clave:
paso8_resumen_vs_snaive12_por_ruta.csv → % de mejora y deltas medios.
paso8_metrics_2024_overall.csv → medias/medianas globales y por ruta.
paso8_intermitentes_alertas.csv → validar casos críticos.
Con eso podemos responder a tu duda: si el modelo masivo predice correctamente las series o si se queda en línea con un baseline trivial.
Vamos a preparar un bloque de análisis que lea estos tres ficheros y saque un resumen interpretativo (ej. % de mejoras, medianas por ruta, distribución de alertas).
Paso 8' - ANÁLISIS RESULTADO POST-PASO 8: Valoración aleatoria del modelo masivo¶
ANALISIS Y DIAGNÓSTICO RESULTADOS MASIVOS¶
Bloque 1 - Constantes y parámetros¶
# ============================================================
# ANÁLISIS PASO 8: Valoración aleatoria del modelo masivo
# Bloque 1) Constantes y parámetros
# ============================================================
# Reutilizamos variables globales si existen; si no, ponemos defaults seguros
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
PATH_PAIR = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv")
PATH_OVER = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_overall.csv")
PATH_VS_SNV = os.path.join(RUTA_REPORTING, "paso8_resumen_vs_snaive12_por_ruta.csv")
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"] # por coherencia
EPS = 1e-8
# Si ya tenemos log8 del Paso 8, lo reutilizamos; si no, definimos uno local
if "log8" not in globals():
from datetime import datetime
LOG_PATH_STEP8_ANALYSIS = os.path.join(ruta_base_3, "LOGS", "estrategia3_step8_analisis.log")
os.makedirs(os.path.dirname(LOG_PATH_STEP8_ANALYSIS), exist_ok=True)
def log8(msg: str):
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP8_ANALYSIS, "a", encoding="utf-8") as f:
f.write(line + "\n")
Bloque 2 - Funciones auxiliares¶
# ============================================================
# Bloque 2) Funciones auxiliares
# ============================================================
def _read_csv_safe(path: str, sep: str = CSV_SEP) -> pd.DataFrame:
"""Leemos un CSV si existe; si no existe, devolvemos DataFrame vacío y avisamos."""
if os.path.exists(path):
return pd.read_csv(path, sep=sep)
log8(f"AVISO: no encontramos {path}. Devolvemos DataFrame vacío para continuar el análisis.")
return pd.DataFrame()
def _pct(x):
"""Formateamos porcentaje de forma robusta."""
try:
return f"{100.0*float(x):.2f}%"
except Exception:
return "NA"
def _num(x, nd=3):
"""Formateamos número con decimales controlados."""
try:
return f"{float(x):.{nd}f}"
except Exception:
return "NA"
def _summary_quantiles(s: pd.Series, qs=(0.1,0.25,0.5,0.75,0.9)):
"""Devolvemos un dict con cuantiles útiles para una serie numérica."""
s = pd.to_numeric(s, errors="coerce").dropna()
if s.empty:
return {}
out = {f"q{int(q*100)}": float(s.quantile(q)) for q in qs}
out["mean"] = float(s.mean())
return out
Bloque 3 - Ejecución del análisis¶
# ============================================================
# Bloque 3) Ejecución del análisis
# ============================================================
# 1) Cargamos artefactos del Paso 8
pair_df = _read_csv_safe(PATH_PAIR)
over_df = _read_csv_safe(PATH_OVER)
vs_df = _read_csv_safe(PATH_VS_SNV)
log8("Iniciamos análisis post–Paso 8.")
# 2) Análisis de comparativa vs SNaive12 (por ruta)
if not vs_df.empty:
# Nos aseguramos de que existan columnas esperadas
expected_cols = {"ruta","n_pairs","pct_mejora_MASE12","pct_mejora_SMAPE","delta_MASE12_medio","delta_SMAPE_medio"}
missing = expected_cols - set(vs_df.columns)
if missing:
log8(f"AVISO: faltan columnas en resumen_vs_snaive12: {missing}. Continuamos con las disponibles.")
# Imprimimos un resumen formateado por ruta
log8("Resumen vs SNaive12 por ruta (siendo negativo mejor que baseline en deltas):")
cols_show = [c for c in ["ruta","n_pairs","pct_mejora_MASE12","pct_mejora_SMAPE","delta_MASE12_medio","delta_SMAPE_medio"] if c in vs_df.columns]
for _, r in vs_df[cols_show].iterrows():
ruta = r.get("ruta","NA")
nps = r.get("n_pairs", np.nan)
pm12 = r.get("pct_mejora_MASE12", np.nan)
psm = r.get("pct_mejora_SMAPE", np.nan)
d12 = r.get("delta_MASE12_medio", np.nan)
dsm = r.get("delta_SMAPE_medio", np.nan)
log8(f"Ruta={ruta} | n_pairs={int(nps) if pd.notna(nps) else 'NA'} | "
f"% mejora MASE12={_pct(pm12)} | % mejora SMAPE={_pct(psm)} | "
f"ΔMASE12 medio={_num(d12)} | ΔSMAPE medio={_num(dsm)}")
# Interpretación operacional
log8("Interpretación: si el % de mejora MASE12 supera el 50% en GENERAL y el ΔMASE12 medio es negativo, concluimos que el modelo supera a SNaive12 en esa ruta.")
else:
log8("No disponemos de paso8_resumen_vs_snaive12_por_ruta.csv; omitimos la comparativa por ruta.")
# 3) Agregados globales y segmentados (overall)
if not over_df.empty:
# Mostramos filas GLOBAL, POR_RUTA y POR_TIPO si existen
if "scope" in over_df.columns:
# Global
gl = over_df[over_df["scope"]=="GLOBAL"]
if not gl.empty:
r = gl.iloc[0].to_dict()
log8("Overall GLOBAL (vista all_months): "
f"MAE_mediana="+_num(r.get("MAE_median"))+", WAPE_mediana="+_num(r.get("WAPE_median"))+
", SMAPE_mediana="+_num(r.get("SMAPE_median"))+", MASE12_mediana="+_num(r.get("MASE12_median")))
# Por ruta
pr = over_df[over_df["scope"]=="POR_RUTA"]
if not pr.empty:
log8("Overall POR_RUTA (medianas):")
for _, row in pr.iterrows():
log8(f" Ruta={row.get('ruta','NA')} | SMAPE_mediana={_num(row.get('SMAPE_median'))} "
f"| MASE12_mediana={_num(row.get('MASE12_median'))}")
# Por tipo de modelo
pt = over_df[over_df["scope"]=="POR_TIPO"]
if not pt.empty:
log8("Overall POR_TIPO (medianas):")
for _, row in pt.iterrows():
log8(f" Tipo={row.get('tipo_modelo','NA')} | SMAPE_mediana={_num(row.get('SMAPE_median'))} "
f"| MASE12_mediana={_num(row.get('MASE12_median'))}")
else:
log8("AVISO: el overall no trae columna 'scope'; mostramos primeras filas para inspección rápida.")
log8(over_df.head(5).to_string(index=False))
else:
log8("No disponemos de paso8_metrics_2024_overall.csv; omitimos agregados globales y segmentados.")
# 4) Profundizamos en la tabla por pareja (vista all_months)
if not pair_df.empty:
# Nos quedamos con la vista oficial del año completo
all_df = pair_df[pair_df.get("vista","all_months")=="all_months"].copy()
# Cuantiles de SMAPE y MASE12 (global)
smape_q = _summary_quantiles(all_df["SMAPE"])
mase12_q = _summary_quantiles(all_df["MASE12"])
log8(f"Distribución SMAPE (all_months) — { {k:_num(v) for k,v in smape_q.items()} }")
log8(f"Distribución MASE12 (all_months) — { {k:_num(v) for k,v in mase12_q.items()} }")
# Por ruta
if "ruta" in all_df.columns:
by_ruta = []
for ruta, g in all_df.groupby("ruta"):
sm_q = _summary_quantiles(g["SMAPE"])
ms_q = _summary_quantiles(g["MASE12"])
by_ruta.append((ruta, sm_q, ms_q))
log8("Distribución por ruta (medianas clave):")
for ruta, sm_q, ms_q in by_ruta:
log8(f" Ruta={ruta} | SMAPE_mediana={_num(sm_q.get('q50'))} | MASE12_mediana={_num(ms_q.get('q50'))}")
# Si vienen columnas de comparativa, calculamos % de victorias otra vez como sanity check
cols_needed = {"MASE12","MASE12_SNaive12"}
if cols_needed.issubset(all_df.columns) and all_df["MASE12_SNaive12"].notna().any():
tmp = all_df.dropna(subset=list(cols_needed)).copy()
tmp["win_MASE12"] = (pd.to_numeric(tmp["MASE12"], errors="coerce") <
pd.to_numeric(tmp["MASE12_SNaive12"], errors="coerce")).astype(int)
win_global = tmp["win_MASE12"].mean()
log8(f"% de parejas que mejoran a SNaive12 en MASE12 (recalculo rápido): {_pct(win_global)}")
if "ruta" in tmp.columns:
win_by_ruta = tmp.groupby("ruta")["win_MASE12"].mean()
for ruta, val in win_by_ruta.items():
log8(f" Ruta={ruta} | % win MASE12={_pct(val)}")
# Interpretación operativa breve
log8("Interpretación: si la mediana de SMAPE y MASE12 del modelo_final es sensiblemente menor que la de los fallback, "
"y además vemos >50% de victorias vs SNaive12, concluimos que el modelo masivo generaliza por encima del baseline.")
else:
log8("No disponemos de paso8_metrics_2024_por_pareja.csv; no podemos profundizar por serie.")
log8("Análisis post–Paso 8 finalizado.")
[2025-09-23T15:53:30] Iniciamos análisis post–Paso 8. [2025-09-23T15:53:30] Resumen vs SNaive12 por ruta (siendo negativo mejor que baseline en deltas): [2025-09-23T15:53:30] Ruta=GENERAL | n_pairs=1032 | % mejora MASE12=64.53% | % mejora SMAPE=62.31% | ΔMASE12 medio=-0.072 | ΔSMAPE medio=-2.262 [2025-09-23T15:53:30] Ruta=INTERMITENTE | n_pairs=1397 | % mejora MASE12=23.84% | % mejora SMAPE=11.38% | ΔMASE12 medio=-0.044 | ΔSMAPE medio=32.836 [2025-09-23T15:53:30] Interpretación: si el % de mejora MASE12 supera el 50% en GENERAL y el ΔMASE12 medio es negativo, concluimos que el modelo supera a SNaive12 en esa ruta. [2025-09-23T15:53:30] Overall GLOBAL (vista all_months): MAE_mediana=159.472, WAPE_mediana=0.858, SMAPE_mediana=63.589, MASE12_mediana=1.046 [2025-09-23T15:53:30] Overall POR_RUTA (medianas): [2025-09-23T15:53:30] Ruta=GENERAL | SMAPE_mediana=42.361 | MASE12_mediana=0.672 [2025-09-23T15:53:30] Ruta=INTERMITENTE | SMAPE_mediana=97.282 | MASE12_mediana=1.676 [2025-09-23T15:53:30] Overall POR_TIPO (medianas): [2025-09-23T15:53:30] Tipo=baseline_cero | SMAPE_mediana=16.667 | MASE12_mediana=nan [2025-09-23T15:53:30] Tipo=fallback_snaive12 | SMAPE_mediana=50.000 | MASE12_mediana=1.378 [2025-09-23T15:53:30] Tipo=modelo_final | SMAPE_mediana=75.220 | MASE12_mediana=0.943 [2025-09-23T15:53:30] Distribución SMAPE (all_months) — {'q10': '10.722', 'q25': '32.529', 'q50': '63.589', 'q75': '125.493', 'q90': '178.561', 'mean': '78.767'} [2025-09-23T15:53:30] Distribución MASE12 (all_months) — {'q10': '0.215', 'q25': '0.445', 'q50': '1.046', 'q75': '2.325', 'q90': '4.622', 'mean': '4.479'} [2025-09-23T15:53:30] Distribución por ruta (medianas clave): [2025-09-23T15:53:30] Ruta=GENERAL | SMAPE_mediana=42.361 | MASE12_mediana=0.672 [2025-09-23T15:53:30] Ruta=INTERMITENTE | SMAPE_mediana=97.282 | MASE12_mediana=1.676 [2025-09-23T15:53:30] Interpretación: si la mediana de SMAPE y MASE12 del modelo_final es sensiblemente menor que la de los fallback, y además vemos >50% de victorias vs SNaive12, concluimos que el modelo masivo generaliza por encima del baseline. [2025-09-23T15:53:30] Análisis post–Paso 8 finalizado.
Conclusiones¶
- Por tipo de ruta
Ruta GENERAL
Ganamos a SNaive12 en MASE12 en 64.5% de las parejas y también en SMAPE en 62.3%; además los deltas medios son mejores (ΔMASE12 = -0.072, ΔSMAPE = -2.26).
Las medianas confirman solvencia: MASE12_mediana = 0.672, SMAPE_mediana = 42.36.
Concluimos que el modelo masivo aporta valor frente al baseline estacional en esta ruta.
Ruta INTERMITENTE
Solo ganamos a SNaive12 en MASE12 en 23.8% y en SMAPE en 11.4%; además ΔSMAPE es positivo (+32.84, peor).
Medianas: MASE12_mediana = 1.676, SMAPE_mediana = 97.28.
Concluimos que en esta ruta el modelo no supera al baseline; necesitamos acciones específicas para segmentar más este grupo.
- Por tipo de fuente
modelo_final: MASE12_mediana = 0.943 (mejor que fallback_snaive12).
fallback_snaive12: MASE12_mediana = 1.378.
baseline_cero: SMAPE bajo por definición en muchos ceros; MASE NA si scale_zero=1, coherente.
Concluimos que donde realmente competimos (modelo_final) mostramos ventaja frente al fallback.
Calidad global:
MASE12_mediana = 1.046 (global).
SMAPE global es alto por colas y ceros; lo esperado con mucha intermitencia.
Hay 198 parejas con n_obs≠12 (falta de meses en 2024) y 106 casos con scale_zero=1 (MASE no evaluable), además de 35 alertas cero->actividad (útiles para negocio).
Propuesta de next steps:
Consolidar victoria en GENERAL
Congelar configuración actual como “v1-general”.
Revisar top-10 peores MASE12 para detectar outliers/errores de datos y reducir cola.
Plan específico para INTERMITENTE
Activar reglas de fallback más agresivas (p.ej., SNaive12 o mediana estacional) cuando la serie tenga alta intermitencia (pocos meses positivos o dispersiones muy altas).
Probar un modelo intermitente tipo Croston/TsVarianza-baja + ZeroInflated o clasificador de ocurrencia + reg. de magnitud.
Evaluar la vista positive_only para aislar el ruido de los ceros y recalibrar pérdida.
- Higiene de datos
Atacar las 198 parejas con n_obs≠12: completar huecos o excluir de métricas oficiales.
Auditar las 35 alertas cero→actividad_2024: pueden indicar cambio estructural real (señal para negocio) o un problema de segmentación.
- Reporting adicional (rápido de sacar con lo que tenemos)
Medianas por positive_only y por rango de n_obs_pos (0, 1-3, 4-8, 9-12).
“Wins vs SNaive12” por familia de
FM_COST_TYPE
.Boxplots de MASE12 por ruta y tipo_modelo (para ver colas).
Conclusión final sobre si esta Estrategia 3 “aporta valor”:
Con los números de GENERAL, la respuesta es sí: superamos al baseline estacional con % de victorias > 50% y deltas medios negativos.
Comparativa grafica de las previsiones con la realidad.
Escenario 1: Elección al azar
Para comparar gráficamente 10 parejas aleatorias del modelo_final con la verdad-terreno de 2024, necesitamos cruzar tres ficheros clave del Paso 8:
RESULTADOS/test_full_2024.csv → verdad-terreno.
RESULTADOS/preds_por_serie_2024.csv → predicciones del modelo_final.
METRICAS/segmentation_intermitencia_step3.csv → para añadir ruta/flags si queremos.
ANÁLISIS GRÁFICO — 10 parejas al azar (real 2024 vs modelo_final)¶
Bloque 1 - Constantes y parámetros¶
# ============================================================
# ANÁLISIS GRÁFICO — 10 parejas al azar (real 2024 vs modelo_final)
# Bloque 1) Constantes y parámetros
# ============================================================
# Reutilizamos globals si existen; si no, definimos por defecto
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_FIGS = os.path.join(ruta_base_3, "FIGS")
os.makedirs(RUTA_FIGS, exist_ok=True)
# Claves y columnas estándar del proyecto
PAIR_COLS = globals().get("PAIR_COLS", ["ID_BUILDING","FM_COST_TYPE"])
DATECOL = globals().get("DATECOL", "FECHA")
VALUE_TRAINTEST = globals().get("VALUE_TRAINTEST", "cost_float_mod") # valor real
PRED_COL = globals().get("PRED_COL", "yhat_combo") # predicción combinada
# Rango 2024 canónico
FREQ = "MS"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq=FREQ)
np.random.seed(42)
Bloque 2 - Funciones auxiliares¶
# ============================================================
# Bloque 2) Funciones auxiliares
# ============================================================
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
"""Normalizamos la columna de fecha a frecuencia mensual (MS)."""
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _agg_dup_and_reindex(df: pd.DataFrame, datecol: str, valuecol: str,
start: str, end: str, pair_cols=PAIR_COLS, agg="sum") -> pd.DataFrame:
"""Agregamos duplicados por (pair, mes) y reindexamos a 2024 MS; rellenamos huecos a 0."""
if df.empty:
return pd.DataFrame(columns=[*pair_cols, datecol, valuecol])
g = (df.groupby(pair_cols + [pd.Grouper(key=datecol, freq="MS")], as_index=False)[valuecol]
.agg(agg))
months = pd.date_range(start, end, freq="MS")
out = []
for keys, sub in g.groupby(pair_cols):
sub = sub.set_index(datecol).reindex(months).rename_axis(datecol).reset_index()
for i, k in enumerate(pair_cols):
sub[k] = keys[i]
sub[valuecol] = sub[valuecol].fillna(0.0)
out.append(sub)
return pd.concat(out, ignore_index=True)[[*pair_cols, datecol, valuecol]]
def _plot_pair(sub_df: pd.DataFrame, bid, ctype, out_dir: str):
"""Generamos la figura por pareja y la guardamos a FIGS además de mostrarla."""
plt.figure(figsize=(10, 4))
plt.plot(sub_df[DATECOL], sub_df[f"{VALUE_TRAINTEST}"], marker="o", label="Real 2024")
plt.plot(sub_df[DATECOL], sub_df[PRED_COL], marker="x", label="Predicción")
plt.title(f"ID_BUILDING={bid} | FM_COST_TYPE={ctype}")
plt.xlabel("Fecha")
plt.ylabel("Coste")
plt.grid(True)
plt.legend()
fname = f"cmp_2024_{bid}_{str(ctype).replace(' ', '_')}.png"
plt.tight_layout()
plt.savefig(os.path.join(out_dir, fname))
plt.show()
Bloque 3 - Ejecución¶
# ============================================================
# Bloque 3) Ejecución
# ============================================================
# 1) Cargamos verdad-terreno y predicciones, y normalizamos FECHA
test_df = pd.read_csv(os.path.join(RUTA_RESULTADOS, "test_full_2024.csv"), sep=CSV_SEP)
preds_df = pd.read_csv(os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv"), sep=CSV_SEP)
test_df = _ensure_ms(test_df, DATECOL)
preds_df = _ensure_ms(preds_df, DATECOL)
# 2) Alineamos a 2024 y resolvemos duplicados por suma (criterio Paso 0)
test_2024 = test_df[[*PAIR_COLS, DATECOL, VALUE_TRAINTEST]].copy()
test_2024 = _agg_dup_and_reindex(test_2024, DATECOL, VALUE_TRAINTEST,
start="2024-01-01", end="2024-12-01", pair_cols=PAIR_COLS, agg="sum")
preds_2024 = preds_df[[*PAIR_COLS, DATECOL, PRED_COL]].copy()
preds_2024 = (preds_2024
.groupby(PAIR_COLS + [DATECOL], as_index=False)[PRED_COL].sum())
# 3) Unimos real y pred por pareja+mes y reindexamos a la malla canónica por pareja
merged = (test_2024
.merge(preds_2024, on=PAIR_COLS + [DATECOL], how="inner"))
# 4) Elegimos 10 parejas al azar de la intersección real∩pred
pairs_all = merged[PAIR_COLS].drop_duplicates()
n_sample = min(10, len(pairs_all))
sample_pairs = pairs_all.sample(n_sample, random_state=42).values.tolist()
# 5) Para cada pareja, reindexamos explícitamente a MONTHS_2024 y graficamos
for bid, ctype in sample_pairs:
sub = merged[(merged[PAIR_COLS[0]] == bid) & (merged[PAIR_COLS[1]] == ctype)].copy()
if sub.empty:
continue
sub = (sub.set_index(DATECOL)
.reindex(MONTHS_2024)
.rename_axis(DATECOL)
.reset_index())
# Rellenamos NaN a 0 por consistencia visual si hay huecos
for col in [VALUE_TRAINTEST, PRED_COL]:
if col in sub.columns:
sub[col] = sub[col].fillna(0.0)
sub[PAIR_COLS[0]] = bid
sub[PAIR_COLS[1]] = ctype
_plot_pair(sub, bid, ctype, RUTA_FIGS)
print(f"Graficadas {n_sample} parejas y guardadas en {RUTA_FIGS}")
Graficadas 10 parejas y guardadas en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/FIGS
Ahora vamos a generar gráficos de comparación entre las predicciones (yhat_combo
) y los valores reales de 2024 (cost_float_mod
) pero en esta ocasión seleccionando las 10 mejores parejas según la métrica MASE12 más bajo en la vista all_months.
# ============================================================
# ANÁLISIS POST–PASO 8: Visualización de los top-10 mejores pares
# ============================================================
# 1) Cargamos métricas y seleccionamos la vista all_months
metrics_df = pd.read_csv(OUT_PAIR, sep=CSV_SEP)
metrics_all = metrics_df[metrics_df["vista"] == "all_months"].copy()
# 2) Seleccionamos las 10 mejores parejas (menor MASE12)
top10_best = metrics_all.sort_values("MASE12", ascending=True).head(10)[PAIR_COLS]
log8(f"Seleccionadas top-10 parejas con mejor MASE12: {top10_best.values.tolist()}")
# 3) Para cada pareja, trazamos la serie real vs predicha
fig, axes = plt.subplots(5, 2, figsize=(14, 18), sharex=True)
axes = axes.flatten()
for i, (bid, ctype) in enumerate(top10_best.values):
ax = axes[i]
# Extraemos serie real
real = test_2024[(test_2024["ID_BUILDING"]==bid) & (test_2024["FM_COST_TYPE"]==ctype)]
pred = preds_2024[(preds_2024["ID_BUILDING"]==bid) & (preds_2024["FM_COST_TYPE"]==ctype)]
if real.empty or pred.empty:
ax.set_title(f"({bid}, {ctype}) — sin datos")
continue
ax.plot(real[DATECOL], real[VALUE_TRAINTEST], marker="o", label="Real 2024")
ax.plot(pred[DATECOL], pred[PRED_COL], marker="x", linestyle="--", label="Predicción")
ax.set_title(f"ID={bid} | Tipo={ctype}")
ax.legend()
ax.grid(True)
plt.tight_layout()
plt.show()
[2025-09-23T13:36:04] Seleccionadas top-10 parejas con mejor MASE12: [[715, 'Licencias'], [1000547, 'Licencias'], [379, 'Licencias'], [1000434, 'Mtto. Contratos'], [1001159, 'Licencias'], [1059, 'Licencias'], [356, 'Licencias'], [1001161, 'Licencias'], [1114, 'Licencias'], [1000596, 'Licencias']]
Observamos que con esta selección nos aparecen 9 series sin coste en el real 2024.
Vamos a eliminar esta opción y seguir listando los top 10 mejores series con actividad.
# ============================================================
# TOP-10 MEJORES (evitando series triviales a cero) Y GRÁFICOS
# ============================================================
# ---------------------------
# Constantes y parámetros
# ---------------------------
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
# Artefactos del Paso 8 y datos 2024
OUT_PAIR = os.path.join(ruta_base_3, "METRICAS", "paso8_metrics_2024_por_pareja.csv")
PATH_TEST = os.path.join(ruta_base_3, "RESULTADOS","test_full_2024.csv")
PATH_PREDS = os.path.join(ruta_base_3, "RESULTADOS","preds_por_serie_2024.csv")
# Convenciones de clave y columnas
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
VALUE_COL = "cost_float_mod" # verdad-terreno
PRED_COL = "yhat_combo" # predicción del modelo
# Malla 2024
FREQ = "MS"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq=FREQ)
# ---------------------------
# Funciones auxiliares
# ---------------------------
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
"""Normalizamos la columna de fecha a mensual (MS)."""
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _agg_dup_and_reindex(df: pd.DataFrame, start: str, end: str,
datecol: str, valuecol: str,
pair_cols=PAIR_COLS, agg="sum") -> pd.DataFrame:
"""Agregamos duplicados por (pair, mes) y reindexamos a malla mensual."""
if df.empty:
return pd.DataFrame(columns=[*pair_cols, datecol, valuecol])
g = (df.groupby(pair_cols + [pd.Grouper(key=datecol, freq="MS")], as_index=False)[valuecol]
.agg(agg))
all_pairs = g[pair_cols].drop_duplicates()
months = pd.date_range(start, end, freq="MS")
out = []
for a, b in all_pairs.values:
sub = g[(g[pair_cols[0]]==a) & (g[pair_cols[1]]==b)].set_index(datecol)
sub = sub.reindex(months).rename_axis(datecol).reset_index()
sub[pair_cols[0]] = a; sub[pair_cols[1]] = b
sub[valuecol] = sub[valuecol].fillna(0.0)
out.append(sub)
return pd.concat(out, ignore_index=True)[[*pair_cols, datecol, valuecol]]
# ---------------------------
# Carga de datos
# ---------------------------
metrics_df = pd.read_csv(OUT_PAIR, sep=CSV_SEP)
metrics_all = metrics_df[metrics_df["vista"] == "all_months"].copy()
# Leemos verdad-terreno y predicciones para construir las curvas
test_df = _ensure_ms(pd.read_csv(PATH_TEST, sep=CSV_SEP), DATECOL)
preds_df = _ensure_ms(pd.read_csv(PATH_PREDS, sep=CSV_SEP), DATECOL)
# Normalizamos y reindexamos a 2024
test_2024 = test_df[[*PAIR_COLS, DATECOL, VALUE_COL]].copy()
test_2024 = _agg_dup_and_reindex(test_2024, "2024-01-01", "2024-12-01", DATECOL, VALUE_COL, PAIR_COLS, agg="sum")
# En preds garantizamos columna PRED_COL
if PRED_COL not in preds_df.columns:
raise ValueError(f"Esperábamos la columna '{PRED_COL}' en preds_por_serie_2024.csv")
preds_2024 = preds_df[[*PAIR_COLS, DATECOL, PRED_COL]].copy()
preds_2024 = (preds_2024
.groupby(PAIR_COLS + [DATECOL], as_index=False)
.agg({PRED_COL: "sum"}))
# ---------------------------
# Filtro para evitar series triviales
# ---------------------------
# Filtramos:
# - tener actividad en 2024 (n_obs_pos > 0)
# - excluir baseline_cero
# - MASE12 válido y positivo
flt = (
(metrics_all["n_obs_pos"] > 0) &
(metrics_all["source_model"].str.lower() != "baseline_cero") &
(metrics_all["MASE12"].notna()) &
(metrics_all["MASE12"] > 0)
)
candidatas = metrics_all.loc[flt].copy()
# Si también queremos evitar SMAPE = 0 por predicción exacta con todo >0, podríamos añadir:
# candidatas = candidatas[candidatas["SMAPE"] > 0]
# Ordenamos por mejor MASE12 y tomamos top-10
top10 = (candidatas
.sort_values(["MASE12","SMAPE","WAPE"], ascending=[True, True, True])
.head(10)
[[*PAIR_COLS, "MASE12", "SMAPE", "WAPE", "source_model"]]
.reset_index(drop=True))
print("Top-10 (ya sin series triviales):")
print(top10)
# ---------------------------
# Gráficos comparación y_true vs y_pred
# ---------------------------
n = len(top10)
cols = 2
rows = int(np.ceil(n / cols))
plt.figure(figsize=(12, 3.5*rows))
for i, row in top10.iterrows():
bid, ctype = row[PAIR_COLS[0]], row[PAIR_COLS[1]]
# Extraemos y_true e y_pred para 2024
te_sub = (test_2024[(test_2024[PAIR_COLS[0]]==bid) & (test_2024[PAIR_COLS[1]]==ctype)]
.set_index(DATECOL)
.reindex(MONTHS_2024))
pr_sub = (preds_2024[(preds_2024[PAIR_COLS[0]]==bid) & (preds_2024[PAIR_COLS[1]]==ctype)]
.set_index(DATECOL)
.reindex(MONTHS_2024))
# Rellenamos a 0 donde falte
y_t = te_sub[VALUE_COL].fillna(0.0).values if VALUE_COL in te_sub.columns else np.zeros(H)
y_p = pr_sub[PRED_COL].fillna(0.0).values if PRED_COL in pr_sub.columns else np.zeros(H)
ax = plt.subplot(rows, cols, i+1)
ax.plot(MONTHS_2024, y_t, label="Real 2024")
ax.plot(MONTHS_2024, y_p, label="Pred 2024", linestyle="--")
ax.set_title(f"{bid} | {ctype}\nMASE12={row['MASE12']:.3f} · SMAPE={row['SMAPE']:.1f} · {row['source_model']}")
ax.set_xlabel("Mes")
ax.set_ylabel("Coste")
ax.grid(True)
ax.legend()
plt.tight_layout()
plt.show()
Top-10 (ya sin series triviales): ID_BUILDING FM_COST_TYPE MASE12 SMAPE WAPE \ 0 2 Obras 0.009560 16.666667 1.000000 1 1075 Servicios Ctto. 0.017328 0.249082 0.002490 2 1000009 Mtto. Contratos 0.019284 0.682261 0.006554 3 1084 Servicios Ctto. 0.027392 0.610810 0.006162 4 1000872 Servicios Ctto. 0.031868 1.588242 0.016501 5 1076 Mtto. Contratos 0.036939 0.682261 0.006554 6 1087 Mtto. Contratos 0.036939 0.682261 0.006554 7 1092 Mtto. Contratos 0.036939 0.682261 0.006554 8 1096 Mtto. Contratos 0.036939 0.682261 0.006554 9 1000632 Mtto. Contratos 0.036939 0.682261 0.006554 source_model 0 fallback_snaive12 1 modelo_final 2 modelo_final 3 modelo_final 4 fallback_snaive12 5 modelo_final 6 modelo_final 7 modelo_final 8 modelo_final 9 modelo_final
Paso 9 - Evaluación final y tableros de métricas¶
Este paso convierte predicciones y observaciones en una “línea de producción de métricas” fiable: mismas reglas para todos, KPIs ponderados por lo que importa (gasto), vistas que responden hacia donde debemos realizar los próximos pasos con señales directas para priorizar mejoras; todo ello con trazabilidad y tests de cordura para no desviarnos del baseline.
Convertimos verdad-terreno (real) y predicciones de 2024 en tableros de métricas que negocio y dirección puedan leer tal cual. Entregamos tablas trazables a nivel serie-mes, resúmenes por segmento y visión de portfolio (mensual y anual), más listados para priorizar acciones.
Estandarizamos:
Fechas al inicio de mes y malla fija enero-diciembre 2024 para todas las series.
Claves limpias y tipos robustos; no negatividad ya garantizada por el modelado.
Ponderación micro como regla de oro: los KPIs agregados siempre reflejan impacto económico real.
Tendremos las siguientes entradas y salidas:
Consumimos las predicciones finales, la verdad-terreno 2024, el panel de series a evaluar y, si existen, las métricas del paso previo y los metadatos de edificios (país, región, uso).
Producimos tres tablas canónicas:
METRICAS/metrics_series_mensual.csv (serie–mes con errores y pesos),
METRICAS/metrics_segmento_mensual.csv (agregados por FM_COST_TYPE/PAIS/REGION/TIPO_USO),
METRICAS/metrics_portfolio_mensual.csv (mensual + anual).
Añadimos listados en REPORTING/ (top-series por contribución al error, segmentos “rojos”), el subset estable.
Calculamos las métricas:
En serie-mes registramos realidad, predicción, error absoluto, una marca de cobertura y el peso económico del mes. Con las escalas adecuadas del histórico incorporamos MASE(1/12) de forma segura; si no hay escala fiable, dejamos el campo vacío antes que forzarlo.
En segmentos calculamos MAE, SMAPE, WAPE micro, MASE(1/12), cobertura efectiva del mes y peso económico acumulado del segmento.
En portfolio, el WAPE mensual y el anual son siempre micro (suma de errores / suma de gasto real). Evitamos promediar WAPEs para no sesgar el impacto.
Cobertura y ponderaciones
Medimos cobertura por mes y cobertura anual (series con al menos una observación) y reportamos la cobertura relativa sobre el total esperado. Los pesos provienen del gasto real absoluto, lo que alinea todos los agregados con la materialidad económica.
Listados para toma decisión y focalización:
Top-10 series por contribución al error anual: un Pareto directo para priorizar correcciones.
Top-5 segmentos por WAPE anual: guía para planes temáticos (familias de coste o geografías).
Sub-dataframe o subset estable (nos quedamos con lo mejor)
Aislamos las series con observación completa en 2024 y buen histórico sin huecos para mostrar el rendimiento “limpio” del método. Si aquí vamos bien y el total flojea, sabemos que el margen está en datos y operativa; si aquí no vamos bien, el foco es modelado.
Control F (obligatorio)
Comprobamos que el WAPE anual del portfolio no empeora al baseline. Si hay referencia externa. Dejamos en log el resultado y, si fallamos, un checklist accionable: confirmar método específico en intermitentes, combinación de top-2 cuando corresponde y fallbacks activados donde tocaba.
Robustez y validaciones
El proceso no se detendrá si faltan dimensiones: seguiremos y lo iremos registrado en el log. Todas las salidas mantendran separador y orden de columnas estables para integrarse en reporting y en la web sin ajustes.
Qué ganamos
Un paquete de evidencias listo para ser consumido: granularidad para auditar, agregados para decidir y rankings para actuar. La lectura es consistente con impacto económico, comparables mes a mes y con una salvaguarda clara (Control F) que evita “mejoras” que no lo son en negocio.
NO EJECUTAR¶
##### NO EJECUTAR ####
# ============================================================
# === Paso 9 - Bloque preliminar: Cargamos dataframe para enriquecer e inspeccionamos las columnas ===
# ============================================================
# ============================================================
# Carga df_fd1_v5_ITE1_train / df_fd1_v5_ITE1_test y verificación de columnas
# ============================================================
# Reutiliza globals si existen
CSV_SEP = globals().get("CSV_SEP", ";")
PATH_E2 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_2"
def _read_any(path_base: str) -> pd.DataFrame:
"""
Intenta leer primero .csv (con CSV_SEP) y si no existe, .xlsx.
Lanza FileNotFoundError si no encuentra ninguno.
"""
path_csv = f"{path_base}.csv"
path_xlsx = f"{path_base}.xlsx"
if os.path.exists(path_csv):
return pd.read_csv(path_csv, sep=CSV_SEP)
if os.path.exists(path_xlsx):
return pd.read_excel(path_xlsx)
raise FileNotFoundError(f"No se encontró ni {path_csv} ni {path_xlsx}")
# 1) Cargamos en variables con el mismo nombre del archivo (sin extensión)
df_fd1_v5_ITE1_train = _read_any(os.path.join(PATH_E2, "df_fd1_v5_ITE1_train"))
df_fd1_v5_ITE1_test = _read_any(os.path.join(PATH_E2, "df_fd1_v5_ITE1_test"))
# 2) Verificamos columnas esperadas
expected_cols = [
"ID_BUILDING","FM_COST_TYPE","MONTH","YEAR","cost_float_mod",
"SUPPLIER_TYPE_MOD_2","FM_RESPONSIBLE_MOD","TIPO_USO",
"ID_REGION_GRUPO","COUNTRY_DEF"
]
missing_train = [c for c in expected_cols if c not in df_fd1_v5_ITE1_train.columns]
missing_test = [c for c in expected_cols if c not in df_fd1_v5_ITE1_test.columns]
print("[verificación] columnas train faltantes:", missing_train)
print("[verificación] columnas test faltantes:", missing_test)
print("[verificación] shape train:", df_fd1_v5_ITE1_train.shape,
"| shape test:", df_fd1_v5_ITE1_test.shape)
[verificación] columnas train faltantes: [] [verificación] columnas test faltantes: [] [verificación] shape train: (65466, 11) | shape test: (23750, 11)
##### NO EJECUTAR ####
# df_fd1_v5_ITE1_test.columns
Index(['ID_BUILDING', 'FM_COST_TYPE', 'FECHA', 'YEAR', 'MONTH', 'cost_float_mod', 'COUNTRY_DEF', 'ID_REGION_GRUPO', 'TIPO_USO', 'SUPPLIER_TYPE_MOD_2', 'FM_RESPONSIBLE_MOD'], dtype='object')
NO EJECUTAR¶
##### NO EJECUTAR ####
# ============================================================
# Construcción de dim_buildings.csv (solo con TEST y TRAIN con dominio de TEST sobre TRAIN)
# ============================================================
# Reutiliza globals si existen
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
PATH_DIM_OUT = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
# --- Utilidades ---
def _norm_keys(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty:
return df
out = df.copy()
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
for c in out.select_dtypes(include="object").columns:
out[c] = out[c].astype(str).str.strip()
return out
def _pick_first_nonnull(s):
return next((v for v in s if pd.notna(v) and str(v).strip() != ""), np.nan)
# --- Normalizamos fuentes (ya cargadas previamente) ---
train_enr = _norm_keys(df_fd1_v5_ITE1_train.copy())
test_enr = _norm_keys(df_fd1_v5_ITE1_test.copy())
# Universo de edificios (incluye los que solo aparecen en 2024)
ids = (pd.concat([train_enr[["ID_BUILDING"]], test_enr[["ID_BUILDING"]]], ignore_index=True)
.drop_duplicates()
.dropna(subset=["ID_BUILDING"]))
# Columnas disponibles en ambos datasets (según tu lista)
cols_meta = [
"COUNTRY_DEF", # país
"ID_REGION_GRUPO", # región/código
"TIPO_USO", # uso edificio
"FM_RESPONSIBLE_MOD", # responsable FM
"SUPPLIER_TYPE_MOD_2" # tipo proveedor
]
def _collapse_meta(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
keep = ["ID_BUILDING"] + [c for c in cols if c in df.columns]
agg = {c: _pick_first_nonnull for c in keep if c != "ID_BUILDING"}
return (df[keep].groupby("ID_BUILDING", as_index=False).agg(agg))
# Agregamos por edificio en cada fuente
meta_train = _collapse_meta(train_enr, cols_meta)
meta_test = _collapse_meta(test_enr, cols_meta)
# Coalesce por edificio: TEST ≻ TRAIN
meta_test_idx = meta_test.set_index("ID_BUILDING")
meta_train_idx = meta_train.set_index("ID_BUILDING")
meta = meta_test_idx.combine_first(meta_train_idx).reset_index()
# Construimos la dimensión con nombres canónicos esperados por Paso 9
dim = meta.rename(columns={
"COUNTRY_DEF": "PAIS",
"ID_REGION_GRUPO": "REGION",
"TIPO_USO": "TIPO_USO",
"FM_RESPONSIBLE_MOD": "FM_RESP",
"SUPPLIER_TYPE_MOD_2": "SUPPLIER"
})[["ID_BUILDING", "PAIS", "REGION", "TIPO_USO", "FM_RESP", "SUPPLIER"]]
# Normalizaciones ligeras
def _norm_upper(x): return str(x).upper() if pd.notna(x) else x
def _norm_title(x):
try:
s = str(x).strip()
return s.title() if s else np.nan
except Exception:
return x
if "PAIS" in dim.columns:
dim["PAIS"] = dim["PAIS"].apply(_norm_upper)
if "REGION" in dim.columns:
dim["REGION"] = dim["REGION"].astype(str).str.strip() # suele ser código; sin title-case
if "TIPO_USO" in dim.columns:
dim["TIPO_USO"] = dim["TIPO_USO"].apply(_norm_title)
# Guardado
dim_out = dim.drop_duplicates().reset_index(drop=True)
dim_out.to_csv(PATH_DIM_OUT, sep=CSV_SEP, index=False)
print(f"[dim_buildings] Guardado: {PATH_DIM_OUT} con {len(dim_out)} edificios.")
print("[dim_buildings] Columnas:", list(dim_out.columns))
[dim_buildings] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/dim_buildings.csv con 561 edificios. [dim_buildings] Columnas: ['ID_BUILDING', 'PAIS', 'REGION', 'TIPO_USO', 'FM_RESP', 'SUPPLIER']
NO EJECUTAR¶
##### NO EJECUTAR ####
# ============================================================
# Construcción de dim_buildings.csv completa - con catálogo FM que domina; fallback con dominio de TEST sobre TRAIN)
# ============================================================
CSV_SEP = globals().get("CSV_SEP", ";")
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
# Carpeta del catálogo FM (tu ruta)
CAT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/CATALOGOS"
# Nombre típico del archivo (ajústalo si cambia en el futuro)
CAT_FILENAME = "Catalogo_inmuebles_Actualizado_a_092025.xlsx"
PATH_FM_CAT = os.path.join(CAT_DIR, CAT_FILENAME)
# Si el archivo exacto no existe, tomamos el último .xlsx del directorio como fallback
if not os.path.exists(PATH_FM_CAT):
cand = sorted(glob.glob(os.path.join(CAT_DIR, "*.xlsx")))
PATH_FM_CAT = cand[-1] if cand else PATH_FM_CAT
# Dim previa (opcional, como fallback)
PATH_DIM_PREV = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
# Salida final (mantenemos el mismo nombre para compatibilidad con Paso 10)
PATH_DIM_FINAL = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
def _norm_id_building(s):
s = pd.to_numeric(s, errors="coerce")
return s.astype("Int64")
def _norm_upper(x):
try:
return str(x).strip().upper() if pd.notna(x) else np.nan
except Exception:
return np.nan
def _norm_str(x):
try:
s = str(x).strip()
return s if s else np.nan
except Exception:
return np.nan
# 1) Leemos catálogo FM (si existe)
if os.path.exists(PATH_FM_CAT):
fm = pd.read_excel(PATH_FM_CAT)
fm.columns = [c.strip() for c in fm.columns]
keep_map = {
"ID_BUILDING": "ID_BUILDING",
"ID_REGION": "REGION", # renombramos a REGION
"COUNTRY": "PAIS", # renombramos a PAIS
"TIPO_USO": "TIPO_USO"
}
fm = fm[[c for c in keep_map if c in fm.columns]].rename(columns=keep_map)
if "ID_BUILDING" in fm.columns:
fm["ID_BUILDING"] = _norm_id_building(fm["ID_BUILDING"])
if "PAIS" in fm.columns:
fm["PAIS"] = fm["PAIS"].apply(_norm_upper)
if "REGION" in fm.columns:
fm["REGION"] = fm["REGION"].apply(_norm_str)
if "TIPO_USO" in fm.columns:
fm["TIPO_USO"] = fm["TIPO_USO"].apply(_norm_str)
fm = fm.drop_duplicates(subset=["ID_BUILDING"]).reset_index(drop=True)
else:
fm = pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION","TIPO_USO"])
# 2) Leemos dim previa (si existe) para buildings que no estén en el catálogo
if os.path.exists(PATH_DIM_PREV):
prev = pd.read_csv(PATH_DIM_PREV, sep=CSV_SEP)
prev.columns = [c.strip() for c in prev.columns]
rename_prev = {}
for c in prev.columns:
cu = c.strip().upper()
if cu == "ID_BUILDING": rename_prev[c] = "ID_BUILDING"
if cu == "PAIS": rename_prev[c] = "PAIS"
if cu == "REGION": rename_prev[c] = "REGION"
if cu == "TIPO_USO": rename_prev[c] = "TIPO_USO"
prev = prev.rename(columns=rename_prev)
if "ID_BUILDING" in prev.columns:
prev["ID_BUILDING"] = _norm_id_building(prev["ID_BUILDING"])
for c in ["PAIS","REGION","TIPO_USO"]:
if c in prev.columns:
prev[c] = prev[c].apply(_norm_str)
else:
prev = pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION","TIPO_USO"])
# 3) Combinamos: catálogo FM domina; añadimos solo los ID_BUILDING que falten
if not fm.empty and not prev.empty:
ids_fm = set(fm["ID_BUILDING"].dropna().tolist())
prev_only = prev[~prev["ID_BUILDING"].isin(ids_fm)]
dim_final = pd.concat([fm, prev_only], ignore_index=True)
elif not fm.empty:
dim_final = fm.copy()
else:
dim_final = prev.copy()
# 4) Limpieza final y guardado
dim_final = dim_final[["ID_BUILDING","PAIS","REGION","TIPO_USO"]].drop_duplicates()
dim_final = dim_final.sort_values("ID_BUILDING").reset_index(drop=True)
dim_final.to_csv(PATH_DIM_FINAL, sep=CSV_SEP, index=False)
# 5) Informe rápido
total = len(dim_final)
n_pais_null = dim_final["PAIS"].isna().sum() if "PAIS" in dim_final.columns else total
n_region_null = dim_final["REGION"].isna().sum() if "REGION" in dim_final.columns else total
print("[dim híbrida] Fuente catálogo:", os.path.basename(PATH_FM_CAT) if os.path.exists(PATH_FM_CAT) else "NO ENCONTRADA")
print("[dim híbrida] Guardado:", PATH_DIM_FINAL, "filas:", total)
print(f"[dim híbrida] %PAIS nulo: {100*n_pais_null/max(total,1):.2f}%")
print(f"[dim híbrida] %REGION nulo: {100*n_region_null/max(total,1):.2f}%")
[dim híbrida] Fuente catálogo: Catalogo_inmuebles_Actualizado_a_092025.xlsx [dim híbrida] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/dim_buildings.csv filas: 1650 [dim híbrida] %PAIS nulo: 0.00% [dim híbrida] %REGION nulo: 0.00%
#### dim_final.head()
ID_BUILDING | PAIS | REGION | TIPO_USO | |
---|---|---|---|---|
0 | 2 | ESPAÑA | 2 | Oficinas |
1 | 9 | ESPAÑA | 2 | Oficinas |
2 | 18 | ESPAÑA | 2 | Oficinas |
3 | 32 | ESPAÑA | 2 | Sin Uso Actual |
4 | 33 | ESPAÑA | 2 | Sin Uso Actual |
NO EJECUTAR¶
#### NO EJECUTAR ####
# ============================================================
# Construcción de dim_buildings.csv (catálogo ≻ fallback TEST≻TRAIN)
# ============================================================
# Reutiliza globals si existen
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
PATH_DIM_OUT = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
# Ruta del catálogo FM
PATH_CATALOGO = "/content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/CATALOGOS/Catalogo_inmuebles_Actualizado_a_092025.xlsx"
# Logger local del bloque (usa log9 si ya existe)
def _dim_log(msg: str):
tag = "[dim_buildings]"
if "log9" in globals() and callable(globals()["log9"]):
globals()["log9"](f"{tag} {msg}")
else:
print(f"{tag} {msg}")
# ---------- Normalizadores vectorizados seguros ----------
import numpy as np
import pandas as pd
def _to_upper_series(s: pd.Series) -> pd.Series:
s = s.astype("string").str.strip()
s = s.mask(s.eq("") | s.str.lower().isin(["nan", "none"]))
return s.str.upper()
def _to_title_series(s: pd.Series) -> pd.Series:
s = s.astype("string").str.strip()
s = s.mask(s.eq("") | s.str.lower().isin(["nan", "none"]))
return s.apply(lambda x: x.title() if isinstance(x, str) else np.nan)
def _norm_id_series(s: pd.Series) -> pd.Series:
return pd.to_numeric(s, errors="coerce").astype("Int64")
# ---------- Intento 1: construir desde CATALOGO ----------
dim_out = pd.DataFrame()
_catalog_ok = False
try:
if os.path.exists(PATH_CATALOGO):
cat = pd.read_excel(PATH_CATALOGO)
# Limpieza de columnas y eliminación de completamente vacías
cat.columns = [str(c).strip() for c in cat.columns]
empty_cols = [c for c in cat.columns if cat[c].isna().all()]
if empty_cols:
cat = cat.drop(columns=empty_cols)
_dim_log(f"[{pd.Timestamp.now().isoformat(timespec='seconds')}] Catálogo cargado: {len(cat)} filas. Columnas: {list(cat.columns)}")
# Consolidamos ID_BUILDING si viene duplicado como 'ID_BUILDING.1'
if "ID_BUILDING.1" in cat.columns:
idc = pd.to_numeric(cat.get("ID_BUILDING"), errors="coerce")
idc2 = pd.to_numeric(cat.get("ID_BUILDING.1"), errors="coerce")
cat["ID_BUILDING"] = idc.fillna(idc2)
# Chequeo de columnas mínimas del catálogo
needed = {"ID_BUILDING","ID_REGION","COUNTRY","STATUS","TIPO_USO"}
if not needed.issubset(set(cat.columns)):
faltan = sorted(list(needed - set(cat.columns)))
_dim_log(f"AVISO: el catálogo no tiene columnas mínimas {faltan}. Intentaremos fallback TEST≻TRAIN.")
else:
# Tipamos y normalizamos
cat["ID_BUILDING"] = _norm_id_series(cat["ID_BUILDING"])
dim_raw = pd.DataFrame({
"ID_BUILDING": cat["ID_BUILDING"],
"PAIS": _to_upper_series(cat["COUNTRY"]),
"REGION": cat["ID_REGION"].astype("string").str.strip().mask(lambda x: x.eq("") | x.str.lower().isin(["nan","none"])),
"TIPO_USO": _to_title_series(cat["TIPO_USO"]),
# STATUS binario para priorizar activos
"STATUS_BIN": cat["STATUS"].astype("string").str.strip().isin(["1","1.0","true","True","ACTIVO","Activo"]).astype(int)
})
# Deduplicamos por ID_BUILDING priorizando activos (STATUS=1)
dim_out = (dim_raw
.sort_values(["ID_BUILDING","STATUS_BIN"], ascending=[True, False])
.drop_duplicates(subset=["ID_BUILDING"], keep="first")
.drop(columns=["STATUS_BIN"])
.dropna(subset=["ID_BUILDING"])
.reset_index(drop=True))
_catalog_ok = len(dim_out) > 0
_dim_log(f"Construido desde catálogo: {len(dim_out)} edificios únicos.")
_dim_log(f"Audit nulos — PAIS={100*dim_out['PAIS'].isna().mean():.1f}% | REGION={100*dim_out['REGION'].isna().mean():.1f}% | TIPO_USO={100*dim_out['TIPO_USO'].isna().mean():.1f}%")
else:
_dim_log(f"AVISO: no existe el fichero de catálogo en {PATH_CATALOGO}. Intentaremos fallback TEST≻TRAIN.")
except Exception as e:
_dim_log(f"AVISO: error construyendo desde catálogo ({e}). Intentaremos fallback TEST≻TRAIN.")
# ---------- Intento 2 (fallback): TEST ≻ TRAIN como tenías ----------
if not _catalog_ok:
_dim_log("Activando fallback TEST≻TRAIN para dim_buildings.csv.")
def _norm_keys(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty:
return df
out = df.copy()
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
for c in out.select_dtypes(include="object").columns:
out[c] = out[c].astype(str).str.strip()
return out
def _pick_first_nonnull(s):
return next((v for v in s if pd.notna(v) and str(v).strip() != ""), np.nan)
# Necesitamos df_fd1_v5_ITE1_train / test ya cargados en memoria (como en tu Paso 9)
if not all(name in globals() for name in ["df_fd1_v5_ITE1_train","df_fd1_v5_ITE1_test"]):
raise RuntimeError("Fallback TEST≻TRAIN requiere df_fd1_v5_ITE1_train y df_fd1_v5_ITE1_test ya cargados en memoria.")
train_enr = _norm_keys(globals()["df_fd1_v5_ITE1_train"].copy())
test_enr = _norm_keys(globals()["df_fd1_v5_ITE1_test"].copy())
cols_meta = [
"COUNTRY_DEF", # país
"ID_REGION_GRUPO", # región/código
"TIPO_USO", # uso edificio
"FM_RESPONSIBLE_MOD", # responsable FM
"SUPPLIER_TYPE_MOD_2" # tipo proveedor
]
def _collapse_meta(df: pd.DataFrame, cols: list[str]) -> pd.DataFrame:
keep = ["ID_BUILDING"] + [c for c in cols if c in df.columns]
agg = {c: _pick_first_nonnull for c in keep if c != "ID_BUILDING"}
return (df[keep].groupby("ID_BUILDING", as_index=False).agg(agg))
meta_train = _collapse_meta(train_enr, cols_meta)
meta_test = _collapse_meta(test_enr, cols_meta)
meta = meta_test.set_index("ID_BUILDING").combine_first(meta_train.set_index("ID_BUILDING")).reset_index()
dim_out = meta.rename(columns={
"COUNTRY_DEF": "PAIS",
"ID_REGION_GRUPO": "REGION",
"TIPO_USO": "TIPO_USO",
"FM_RESPONSIBLE_MOD": "FM_RESP",
"SUPPLIER_TYPE_MOD_2": "SUPPLIER"
})[["ID_BUILDING", "PAIS", "REGION", "TIPO_USO", "FM_RESP", "SUPPLIER"]]
# Normalizaciones suaves
dim_out["PAIS"] = _to_upper_series(dim_out["PAIS"]) if "PAIS" in dim_out.columns else dim_out.get("PAIS")
if "REGION" in dim_out.columns:
dim_out["REGION"] = dim_out["REGION"].astype("string").str.strip()
if "TIPO_USO" in dim_out.columns:
dim_out["TIPO_USO"] = _to_title_series(dim_out["TIPO_USO"])
dim_out = dim_out.drop_duplicates().reset_index(drop=True)
_dim_log(f"Fallback TEST≻TRAIN construido: {len(dim_out)} edificios.")
# ---------- Guardado ----------
dim_out.to_csv(PATH_DIM_OUT, sep=CSV_SEP, index=False)
_dim_log(f"Guardado: {PATH_DIM_OUT} con {len(dim_out)} edificios.")
_dim_log(f"Columnas finales: {list(dim_out.columns)}")
[dim_buildings] [2025-09-24T12:17:53] [dim_buildings] [2025-09-24T12:17:53] Catálogo cargado: 1650 filas. Columnas: ['ID_BUILDING', 'ID_REGION', 'COUNTRY', 'STATUS', 'FM_PERIMETER', 'SERVICE_LEVEL', 'TIPO_USO', 'M2_AVAIL_PERIMETER', 'M2_AVAIL', 'M2_PROM_USO', 'ID_BUILDING.1'] [dim_buildings] [2025-09-24T12:17:53] [dim_buildings] Construido desde catálogo: 1650 edificios únicos. [dim_buildings] [2025-09-24T12:17:53] [dim_buildings] Audit nulos — PAIS=0.0% | REGION=0.0% | TIPO_USO=0.0% [dim_buildings] [2025-09-24T12:17:53] [dim_buildings] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/dim_buildings.csv con 1650 edificios. [dim_buildings] [2025-09-24T12:17:53] [dim_buildings] Columnas finales: ['ID_BUILDING', 'PAIS', 'REGION', 'TIPO_USO']
Generamos dim_building.csv¶
# ============================================================
# Bloque Preliminar: Construcción de dim_buildings.csv DESDE CATÁLOGO
# ============================================================
# Rutas (mantenemos nombres de la Estrategia 3)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
PATH_DIM_OUT = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
# Catálogo fuente (status actual de los inmuebles)
CATALOGO_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/FUENTE_DATOS/CATALOGOS"
CATALOGO_XLSX = os.path.join(CATALOGO_DIR, "Catalogo_inmuebles_Actualizado_a_092025.xlsx")
# -----------------------
# Helpers de normalización
# -----------------------
def _norm_id_building(series: pd.Series) -> pd.Series:
# Tipamos a entero nullable para conservar posibles NaNs
return pd.to_numeric(series, errors="coerce").astype("Int64")
def _norm_upper_series(series: pd.Series) -> pd.Series:
s = series.astype(str).str.strip()
s = s.replace({"": np.nan, "nan": np.nan, "None": np.nan})
return s.str.upper()
def _norm_title_series(series: pd.Series) -> pd.Series:
# Title-case seguro; si viene NaN lo dejamos como tal
return series.apply(lambda x: str(x).strip().title() if pd.notna(x) and str(x).strip() != "" else np.nan)
def _norm_status(series: pd.Series) -> pd.Series:
# Mapeamos a 0/1 de forma robusta
s = pd.to_numeric(series, errors="coerce")
s = s.fillna(0).astype(int)
s = s.clip(lower=0, upper=1)
return s
# ---------------
# Carga Catálogo
# ---------------
cat = pd.read_excel(CATALOGO_XLSX)
# Eliminamos columnas completamente vacías (p.ej. 'Unnamed: 10', etc.)
empty_cols = [c for c in cat.columns if cat[c].isna().all()]
cat = cat.drop(columns=empty_cols)
print(f"[dim_buildings] [{datetime.now().isoformat(timespec='seconds')}] "
f"Catálogo cargado: {len(cat)} filas. Columnas: {list(cat.columns)}")
# Renombrado y selección de columnas canónicas
# (el catálogo trae: ID_BUILDING, ID_REGION, COUNTRY, STATUS, TIPO_USO, ...)
cols_needed = ["ID_BUILDING", "COUNTRY", "ID_REGION", "TIPO_USO", "STATUS"]
missing = [c for c in cols_needed if c not in cat.columns]
if missing:
raise KeyError(f"Faltan columnas en el catálogo: {missing}. Revisa {CATALOGO_XLSX}")
# Normalización de valores
dim_raw = pd.DataFrame({
"ID_BUILDING": _norm_id_building(cat["ID_BUILDING"]),
"PAIS": _norm_upper_series(cat["COUNTRY"]),
"REGION": cat["ID_REGION"].astype(str).str.strip().replace({"": np.nan}),
"TIPO_USO": _norm_title_series(cat["TIPO_USO"]),
"STATUS": _norm_status(cat["STATUS"]),
})
# Quitamos filas sin ID_BUILDING y deduplicamos por seguridad (no se espera histórico aquí)
dim_out = (dim_raw
.dropna(subset=["ID_BUILDING"])
.drop_duplicates(subset=["ID_BUILDING"])
.reset_index(drop=True))
# Guardado
dim_out.to_csv(PATH_DIM_OUT, sep=CSV_SEP, index=False)
# Resumen final
n_total = len(dim_out)
n_activos = int(dim_out["STATUS"].eq(1).sum())
n_baja = int(dim_out["STATUS"].eq(0).sum())
print(f"[dim_buildings] Guardado: {PATH_DIM_OUT} con {n_total} edificios. "
f"Activos={n_activos} | Baja={n_baja}")
print("[dim_buildings] Columnas:", list(dim_out.columns))
[dim_buildings] [2025-09-24T12:35:32] Catálogo cargado: 1650 filas. Columnas: ['ID_BUILDING', 'ID_REGION', 'COUNTRY', 'STATUS', 'FM_PERIMETER', 'SERVICE_LEVEL', 'TIPO_USO', 'M2_AVAIL_PERIMETER', 'M2_AVAIL', 'M2_PROM_USO', 'ID_BUILDING.1'] [dim_buildings] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/dim_buildings.csv con 1650 edificios. Activos=826 | Baja=824 [dim_buildings] Columnas: ['ID_BUILDING', 'PAIS', 'REGION', 'TIPO_USO', 'STATUS']
Bloque 0 -Parámetros, rutas y logging¶
# ============================================================
# PASO 9 · Evaluación final y tableros de métricas (negocio/dirección)
# Bloque 0) Parámetros, rutas y logging
# ============================================================
# Fijamos semilla para reproducibilidad
np.random.seed(7)
# Reutilizamos variables globales del proyecto si ya existen; si no, ponemos defaults seguros
ruta_base_3 = globals().get("ruta_base_3", "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3")
CSV_SEP = globals().get("CSV_SEP", ";")
PAIR_COLS = globals().get("PAIR_COLS", ["ID_BUILDING", "FM_COST_TYPE"])
DATECOL = globals().get("DATECOL", "FECHA")
VALUE_TRAINTEST = globals().get("VALUE_TRAINTEST", "cost_float_mod")
# Frecuencia y malla canónica
FREQ = "MS"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq=FREQ)
# Carpeta de trabajo
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
for _d in [RUTA_RESULTADOS, RUTA_METRICAS, RUTA_LOGS, RUTA_REPORTING]:
os.makedirs(_d, exist_ok=True)
# Entradas
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv")
PATH_TEST = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv")
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv")
PATH_FINAL = os.path.join(RUTA_METRICAS, "modelo_final_paso6.csv")
PATH_PASO8 = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv") # insumo opcional
PATH_BASES = os.path.join(RUTA_METRICAS, "metrics_baselines_cv.csv") # opcional
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # opcional
# Salidas
OUT_SERIES = os.path.join(RUTA_METRICAS, "metrics_series_mensual.csv")
OUT_SEGMENTOS = os.path.join(RUTA_METRICAS, "metrics_segmento_mensual.csv")
OUT_PORTFOLIO = os.path.join(RUTA_METRICAS, "metrics_portfolio_mensual.csv")
OUT_TOP10 = os.path.join(RUTA_REPORTING, "top10_series_contrib_error.csv")
OUT_TOP5 = os.path.join(RUTA_REPORTING, "top5_segmentos_rojos_WAPE.csv")
OUT_SUBSET = os.path.join(RUTA_METRICAS, "metrics_subset_estable.csv")
OUT_COMPARE_MOD3 = os.path.join(RUTA_REPORTING, "compare_portfolio_mod3.csv")
# Logging
LOG_PATH_STEP9 = os.path.join(RUTA_LOGS, "estrategia3_step9.log")
def log9(msg: str):
"""Escribimos mensajes del Paso 9 en el log y en consola para trazabilidad."""
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH_STEP9, "a", encoding="utf-8") as f:
f.write(line + "\n")
Bloque 1 - Utilidades de carga, fechas y normalización¶
# ============================================================
# Bloque 1) Utilidades de carga, fechas y normalización
# ============================================================
EPS = 1e-8
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
# Normalizamos la fecha a comienzo de mes para alinear todo a la malla MS
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _read_csv(path: str, required: bool=True, sep: str=CSV_SEP) -> pd.DataFrame:
if os.path.exists(path):
df = pd.read_csv(path, sep=sep)
# Normalización fuerte de nombres de columnas (espacios/BOM)
df.columns = [str(c).strip().lstrip("\ufeff").rstrip() for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta el archivo requerido: {path}")
log9(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _norm_pair_keys(df: pd.DataFrame) -> pd.DataFrame:
"""Normaliza claves y asegura que existan exactamente 'ID_BUILDING' y 'FM_COST_TYPE'."""
if df is None or df.empty:
return df
out = df.copy()
# 2.1 Limpia nombres de columnas
out.columns = [str(c).strip().lstrip("\ufeff").rstrip() for c in out.columns]
# 2.2 Detecta y renombra alias (por si aparece con otro case o con espacios)
cols_lower = {c.lower(): c for c in out.columns}
if "id_building" in cols_lower and "ID_BUILDING" not in out.columns:
out = out.rename(columns={cols_lower["id_building"]: "ID_BUILDING"})
if "fm_cost_type" in cols_lower and "FM_COST_TYPE" not in out.columns:
out = out.rename(columns={cols_lower["fm_cost_type"]: "FM_COST_TYPE"})
# 2.3 Tipea y limpia valores
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
if "FM_COST_TYPE" in out.columns and out["FM_COST_TYPE"].dtype == object:
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
def _agg_dup_and_reindex(df: pd.DataFrame, datecol: str, valuecol: str,
months: pd.DatetimeIndex, pair_cols=PAIR_COLS, agg="sum") -> pd.DataFrame:
# Agregamos duplicados por (pair, mes) y reindexamos a la malla fija de meses
if df.empty:
return pd.DataFrame(columns=[*pair_cols, datecol, valuecol])
g = (df.groupby(pair_cols + [pd.Grouper(key=datecol, freq="MS")], as_index=False)[valuecol]
.agg(agg))
out = []
for a, b in g[pair_cols].drop_duplicates().values:
sub = g[(g[pair_cols[0]]==a) & (g[pair_cols[1]]==b)].set_index(datecol)
sub = sub.reindex(months).rename_axis(datecol).reset_index()
sub[pair_cols[0]] = a; sub[pair_cols[1]] = b
sub[valuecol] = sub[valuecol].fillna(np.nan) # dejamos NaN para cobertura
out.append(sub)
return pd.concat(out, ignore_index=True)[[*pair_cols, datecol, valuecol]]
Bloque 2 - Funciones Métricas y escalas MASE¶
# ============================================================
# Bloque 2) Métricas y escalas MASE
# ============================================================
def _safe_div(num, den, eps=EPS):
return num / (den + eps)
def mae_vec(y_true, y_pred):
# MAE como media de errores absolutos en el conjunto evaluado
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(np.mean(np.abs(y_pred - y_true)))
def wape_micro(y_true, y_pred, eps=EPS):
# WAPE micro: sumatorio de |e| sobre sumatorio de |y|
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(_safe_div(np.sum(np.abs(y_pred - y_true)), np.sum(np.abs(y_true)), eps))
def smape_vec(y_true, y_pred, eps=EPS):
# SMAPE medio con denominador simétrico y eps para evitar divisiones por 0
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = (np.abs(y_true) + np.abs(y_pred)) / 2.0
return float(np.mean(_safe_div(num, den, eps)) * 100.0)
def _scale_diff(series: np.ndarray, lag: int):
# Escala para MASE: media de |y_t - y_{t-lag}| sobre el histórico
arr = np.asarray(series, dtype=float)
if len(arr) <= lag:
return np.nan
diffs = np.abs(arr[lag:] - arr[:-lag])
if diffs.size == 0:
return np.nan
return float(np.mean(diffs))
def mase_from_scale(errors: np.ndarray, scale: float):
# MASE con escala precomputada; si la escala no es válida, devolvemos NaN
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
errors = np.asarray(errors, dtype=float)
return float(np.mean(np.abs(errors)) / float(scale))
def mase_point_abs_err(abs_err: float, scale: float):
# MASE puntual por fila (lo usamos para MASE1/MASE12 en serie–mes)
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
return float(abs_err / float(scale))
Bloque 3 - Baseline SNaive12 para Control F (último año completo -> mediana estacional -> último valor)¶
# ============================================================
# Bloque 3) Baseline SNaive12 para Control F (último año completo -> mediana estacional -> último valor)
# ============================================================
def _last_complete_year_map(y: np.ndarray, dates: np.ndarray):
# Buscamos el último año con los 12 meses completos y devolvemos mapa mes->valor
if y.size == 0 or dates.size == 0:
return {}
df = pd.DataFrame({"FECHA": pd.to_datetime(dates), "y": y})
df["YEAR"] = df["FECHA"].dt.year
df["MONTH"] = df["FECHA"].dt.month
years = sorted(df["YEAR"].unique())[::-1]
for year in years:
sub = df[df["YEAR"] == year]
if sub["MONTH"].nunique() == 12:
return sub.set_index("MONTH")["y"].to_dict()
return {}
def _median_by_month(y: np.ndarray, dates: np.ndarray):
if y.size == 0 or dates.size == 0:
return {}
df = pd.DataFrame({"FECHA": pd.to_datetime(dates), "y": y})
df["MONTH"] = df["FECHA"].dt.month
return df.groupby("MONTH")["y"].median().to_dict()
def snaive12_forecast(y_train: np.ndarray, dates_train: np.ndarray, horizon: int):
# Implementamos SNaive12 con la misma lógica del proyecto
if horizon <= 0:
return np.zeros(0, dtype=float)
if y_train.size == 0:
return np.zeros(horizon, dtype=float)
last_date = pd.to_datetime(dates_train[-1])
future = pd.date_range(last_date.to_period("M").to_timestamp() + pd.offsets.MonthBegin(1),
periods=horizon, freq="MS")
last_y = float(y_train[-1])
m = _last_complete_year_map(y_train, dates_train)
if len(m) == 12:
return np.array([m.get(d.month, last_y) for d in future], dtype=float)
med = _median_by_month(y_train, dates_train)
if len(med) > 0:
return np.array([med.get(d.month, last_y) for d in future], dtype=float)
return np.repeat(last_y, horizon).astype(float)
Bloque 4 - Carga y preparación de datos¶
# ============================================================
# Bloque 4) Carga y preparación de datos (con validación)
# ============================================================
# --- Utilidad de validación ---
def _ensure_pair_cols(df: pd.DataFrame, name: str, require: list[str] | None = None) -> pd.DataFrame:
"""
Verifica que el DataFrame contenga las columnas definidas en PAIR_COLS
y, opcionalmente, otras columnas requeridas (require).
Si faltan, lanza KeyError. Devuelve el mismo DataFrame si está correcto.
"""
need = set(PAIR_COLS)
if require:
need |= set(require)
have = set(df.columns)
missing = list(need - have)
if missing:
raise KeyError(f"[{name}] faltan columnas {missing}. Columnas presentes: {sorted(have)}")
return df
# --- Cargamos entradas canónicas ---
train_df = _ensure_ms(_read_csv(PATH_TRAIN, required=True), DATECOL)
test_df = _ensure_ms(_read_csv(PATH_TEST, required=True), DATECOL)
preds_df = _ensure_ms(_read_csv(PATH_PREDS, required=True), DATECOL)
final_df = _read_csv(PATH_FINAL, required=True)
paso8_df = _read_csv(PATH_PASO8, required=False) # opcional
bases_df = _read_csv(PATH_BASES, required=False) # opcional
# --- Validaciones tempranas de esquema ---
_ensure_pair_cols(train_df, "train_df", require=[DATECOL, VALUE_TRAINTEST])
_ensure_pair_cols(test_df, "test_df", require=[DATECOL, VALUE_TRAINTEST])
_ensure_pair_cols(preds_df, "preds_df", require=[DATECOL, "yhat_combo"])
_ensure_pair_cols(final_df, "final_df") # aquí solo exigimos PAIR_COLS
# Dimensiones opcionales (PAIS, REGION, TIPO_USO)
dim_df = _read_csv(PATH_DIM, required=False)
if not dim_df.empty:
keep = [c for c in ["ID_BUILDING","PAIS","REGION","TIPO_USO"] if c in dim_df.columns]
dim_df = dim_df[keep].drop_duplicates()
else:
dim_df = pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION","TIPO_USO"])
# --- Normalizamos claves ---
train_df = _norm_pair_keys(train_df)
test_df = _norm_pair_keys(test_df)
preds_df = _norm_pair_keys(preds_df)
final_df = _norm_pair_keys(final_df)
dim_df = _norm_pair_keys(dim_df)
# Revalidamos tras normalización (por si algún cast dejara NaNs/strings raros)
_ensure_pair_cols(train_df, "train_df(norm)", require=[DATECOL, VALUE_TRAINTEST])
_ensure_pair_cols(test_df, "test_df(norm)", require=[DATECOL, VALUE_TRAINTEST])
_ensure_pair_cols(preds_df, "preds_df(norm)", require=[DATECOL, "yhat_combo"])
_ensure_pair_cols(final_df, "final_df(norm)")
# --- Construimos malla 2024 para verdad-terreno agregada y reindexada ---
test_2024 = test_df[[*PAIR_COLS, DATECOL, VALUE_TRAINTEST]].copy()
test_2024 = _agg_dup_and_reindex(
test_2024, DATECOL, VALUE_TRAINTEST,
months=MONTHS_2024, pair_cols=PAIR_COLS, agg="sum"
)
# --- Predicciones 2024 (aseguramos 12 filas por pareja) ---
preds_2024 = (preds_df
.groupby(PAIR_COLS + [DATECOL], as_index=False)
.agg({"yhat_combo":"sum"}))
preds_2024 = _agg_dup_and_reindex(
preds_2024, DATECOL, "yhat_combo",
months=MONTHS_2024, pair_cols=PAIR_COLS, agg="sum"
)
# --- Panel de evaluación (universo del modelo_final del Paso 6) ---
panel_pairs = final_df[PAIR_COLS].drop_duplicates().copy()
total_series_portfolio = panel_pairs.shape[0]
log9(f"Total de series en modelo_final (panel): {total_series_portfolio}")
# --- Unimos verdad y predicción a nivel serie–mes ---
series_month = (panel_pairs
.merge(test_2024, on=PAIR_COLS, how="left")
.merge(preds_2024, on=PAIR_COLS + [DATECOL], how="left"))
# --- Renombrados y campos base ---
series_month = series_month.rename(columns={VALUE_TRAINTEST: "y_true", "yhat_combo": "y_pred"})
series_month["abs_err"] = np.abs(series_month["y_pred"] - series_month["y_true"])
series_month["cobertura_mes"] = (~series_month["y_true"].isna()).astype(int)
series_month["peso_gasto_mes"] = np.abs(series_month["y_true"]).fillna(0.0)
# --- Adjuntamos dimensiones si existen ---
series_month = series_month.merge(dim_df, on="ID_BUILDING", how="left")
for c in ["PAIS","REGION","TIPO_USO"]:
if c not in series_month.columns:
series_month[c] = None
[2025-09-24T12:36:58] Total de series en modelo_final (panel): 2429
Bloque 5 - Escalas MASE por serie y métricas por fila¶
# ============================================================
# Bloque 5) Escalas MASE por serie y métricas por fila
# ============================================================
# Preparamos escalas MASE1/MASE12 por serie usando train 2021–2023
train_idx = (_ensure_ms(train_df, DATECOL)
.sort_values(PAIR_COLS + [DATECOL])
.set_index(PAIR_COLS))
scales = {}
for key, g in train_idx.groupby(level=[0,1]):
y_hist = g[VALUE_TRAINTEST].astype(float).values
s1 = _scale_diff(y_hist, 1)
s12 = _scale_diff(y_hist, 12)
scales[key] = {"scale1": s1, "scale12": s12}
def _lookup_scale(row):
key = (row[PAIR_COLS[0]], row[PAIR_COLS[1]])
sc = scales.get(key, {"scale1": np.nan, "scale12": np.nan})
return pd.Series({"scale1": sc["scale1"], "scale12": sc["scale12"]})
series_month = series_month.merge(
series_month.apply(_lookup_scale, axis=1),
left_index=True, right_index=True
)
# Métricas por fila: MAE fila = abs_err; WAPE fila = abs_err / |y_true|; SMAPE puntual
def _smape_point(y, yhat, eps=EPS):
y = float(y) if pd.notna(y) else np.nan
yhat = float(yhat) if pd.notna(yhat) else np.nan
if np.isnan(y) or np.isnan(yhat):
return np.nan
den = max((abs(y) + abs(yhat))/2.0, eps)
return 100.0 * abs(yhat - y) / den
series_month["MAE"] = series_month["abs_err"]
series_month["WAPE"] = _safe_div(series_month["abs_err"], series_month["peso_gasto_mes"].replace(0.0, np.nan)).replace([np.inf, -np.inf], np.nan)
series_month["SMAPE"] = series_month.apply(lambda r: _smape_point(r["y_true"], r["y_pred"], eps=EPS), axis=1)
series_month["MASE1"] = series_month.apply(lambda r: mase_point_abs_err(r["abs_err"], r["scale1"]), axis=1)
series_month["MASE12"] = series_month.apply(lambda r: mase_point_abs_err(r["abs_err"], r["scale12"]), axis=1)
# Orden estable de columnas y guardado
cols_series = [*PAIR_COLS, DATECOL, "y_true", "y_pred", "abs_err", "MAE", "SMAPE", "WAPE", "MASE1", "MASE12",
"cobertura_mes", "peso_gasto_mes", "PAIS", "REGION", "TIPO_USO"]
series_month = series_month[cols_series].sort_values(PAIR_COLS + [DATECOL]).reset_index(drop=True)
series_month.to_csv(OUT_SERIES, sep=CSV_SEP, index=False)
log9(f"Guardado: {OUT_SERIES} con {len(series_month)} filas.")
[2025-09-24T12:37:08] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_series_mensual.csv con 26970 filas.
Chequeamos la cobertura del qué falta en el conjunto de metricas¶
# ----- Diagnóstico de cobertura y duplicados -----
print("[check] filas esperadas si todas las series tienen 12 meses:", panel_pairs.shape[0] * 12)
print("[check] filas reales en series_month:", len(series_month))
# pares únicos y meses por par
cov = (series_month.groupby(PAIR_COLS, as_index=False)[DATECOL]
.nunique().rename(columns={DATECOL: "n_meses"}))
print("[check] distribución n_meses por serie:")
print(cov["n_meses"].value_counts().sort_index())
# pares con n_meses != 12
bad = cov[cov["n_meses"] != 12]
print(f"[check] series con n_meses != 12: {len(bad)} (muestra 10)")
print(bad.head(10))
# ¿hay duplicados exactos de (pair, FECHA)?
dups = series_month.duplicated(subset=PAIR_COLS + [DATECOL]).sum()
print("[check] duplicados exactos (pair, FECHA):", dups)
# ¿cuántos pares del panel no aparecen en test_2024?
pairs_test = test_2024[PAIR_COLS].drop_duplicates()
missing_in_test = (panel_pairs.merge(pairs_test, on=PAIR_COLS, how="left", indicator=True)
.query("_merge=='left_only'")[PAIR_COLS])
print("[check] pares del panel sin filas en test_2024:", len(missing_in_test))
print(missing_in_test.head(10))
[check] filas esperadas si todas las series tienen 12 meses: 29148 [check] filas reales en series_month: 26970 [check] distribución n_meses por serie: n_meses 0 198 12 2231 Name: count, dtype: int64 [check] series con n_meses != 12: 198 (muestra 10) ID_BUILDING FM_COST_TYPE n_meses 0 2 Eficiencia Energética 0 24 57 Eficiencia Energética 0 25 57 Licencias 0 27 57 Mtto. Correctivo 0 28 57 Servicios Ctto. 0 29 57 Servicios Extra 0 30 57 Suministros 0 31 59 Licencias 0 33 59 Mtto. Correctivo 0 34 59 Obras 0 [check] duplicados exactos (pair, FECHA): 0 [check] pares del panel sin filas en test_2024: 198 ID_BUILDING FM_COST_TYPE 0 2 Eficiencia Energética 24 57 Eficiencia Energética 25 57 Licencias 27 57 Mtto. Correctivo 28 57 Servicios Ctto. 29 57 Servicios Extra 30 57 Suministros 31 59 Licencias 33 59 Mtto. Correctivo 34 59 Obras
Interpretación checkeo¶
Eso nos encaja exactamente con lo que pasó en el Paso 1 / Punto de control A: se detectaron 198 parejas SOLO TRAIN (con histórico 2021-2023 pero sin datos en 2024). Cuando construimos el test_2024, esas series no aparecían y al hacer el merge con el panel (2429) -> se quedan 198 sin filas (n_meses = 0).
Es el reflejo de la segmentación inicial:
2429 totales en modelo_final.
198 eran SOLO TRAIN -> en 2024 no tienen test.
2231 sí tienen los 12 meses de 2024.
Bloque 6 - Agregación por segmento–mes¶
# ============================================================
# Bloque 6) Agregación por segmento–mes
# ============================================================
# Preparamos función de agregado por dimensión
def _agg_segment_month(df: pd.DataFrame, dim_col: str, dim_name: str, total_series_segmento: pd.DataFrame):
# Calculamos métricas agregadas por mes, además de cobertura relativa y pesos
g = df.groupby([dim_col, DATECOL], as_index=False)
out = g.apply(lambda d: pd.Series({
"MAE": d["MAE"].mean(),
"SMAPE": d["SMAPE"].mean(),
"WAPE": wape_micro(d["y_true"].values, d["y_pred"].values, eps=EPS),
"MASE1": d["MASE1"].mean(),
"MASE12": d["MASE12"].mean(),
"n_series_mes": int(d.loc[d["cobertura_mes"]==1, [PAIR_COLS[0],PAIR_COLS[1]]].drop_duplicates().shape[0]),
"peso_gasto_mes": float(np.nansum(np.abs(d["y_true"]))),
})).reset_index(drop=True)
# Cobertura relativa: n_series_mes / total_series_segmento
out = out.merge(total_series_segmento, left_on=dim_col, right_on=dim_col, how="left")
out["cobertura_relativa"] = out["n_series_mes"] / out["total_series_segmento"].replace(0, np.nan)
# Añadimos identificadores de nivel y clave
out["nivel_segmento"] = dim_name
out = out.rename(columns={dim_col: "clave_segmento"})
# Orden de columnas
out = out[["nivel_segmento","clave_segmento", DATECOL,
"MAE","SMAPE","WAPE","MASE1","MASE12",
"n_series_mes","peso_gasto_mes","cobertura_relativa"]]
return out
# Preparamos conteos de universo por dimensión (series posibles por segmento)
def _universe_by_dim(panel_pairs: pd.DataFrame, series_month: pd.DataFrame, dim_col: str):
# Determinamos el universo de pares por segmento (tomamos únicos del panel final)
if dim_col == "FM_COST_TYPE":
base = panel_pairs.copy()
base = base.rename(columns={PAIR_COLS[1]: "FM_COST_TYPE"})
u = base.groupby("FM_COST_TYPE")[PAIR_COLS].apply(lambda x: x.drop_duplicates().shape[0]).rename("total_series_segmento").reset_index()
return u.rename(columns={"FM_COST_TYPE": dim_col})
else:
if dim_col not in series_month.columns:
return pd.DataFrame(columns=[dim_col,"total_series_segmento"])
base = (series_month[[*PAIR_COLS, dim_col]]
.dropna(subset=[dim_col])
.drop_duplicates())
u = base.groupby(dim_col)[PAIR_COLS].apply(lambda x: x.drop_duplicates().shape[0]).rename("total_series_segmento").reset_index()
return u
# Preparamos las dimensiones disponibles
dim_specs = [("FM_COST_TYPE","FM_COST_TYPE"), ("PAIS","PAIS"), ("REGION","REGION"), ("TIPO_USO","TIPO_USO")]
segment_frames = []
for dim_col, dim_name in dim_specs:
if (dim_col == "FM_COST_TYPE") or (dim_col in series_month.columns):
uni = _universe_by_dim(panel_pairs, series_month, dim_col)
seg_df = _agg_segment_month(series_month, dim_col, dim_name, uni)
segment_frames.append(seg_df)
else:
log9(f"AVISO: omitimos dimensión {dim_col} por no disponer de metadatos.")
metrics_segmento = pd.concat(segment_frames, ignore_index=True) if len(segment_frames)>0 else pd.DataFrame(
columns=["nivel_segmento","clave_segmento",DATECOL,"MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","cobertura_relativa"]
)
metrics_segmento = metrics_segmento.sort_values(["nivel_segmento","clave_segmento",DATECOL]).reset_index(drop=True)
metrics_segmento.to_csv(OUT_SEGMENTOS, sep=CSV_SEP, index=False)
log9(f"Guardado: {OUT_SEGMENTOS} con {len(metrics_segmento)} filas.")
[2025-09-24T12:37:11] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_segmento_mensual.csv con 1560 filas.
Ya podemos analizar rendimiento no solo a nivel de cada serie, sino también en cortes agregados:
Por tipo de coste (FM_COST_TYPE).
Por país (PAIS).
Por región (REGION).
Por uso (TIPO_USO).
Incluimos también la métrica cobertura_relativa
, que te indica qué % de las series esperadas en ese segmento efectivamente tienen datos en cada mes.
Bloque 7 - Agregación de portfolio mensual y anual¶
# ============================================================
# Bloque 7) Agregación de portfolio mensual y anual
# ============================================================
# Mensual
g = series_month.groupby(DATECOL, as_index=False)
portfolio_month = g.apply(lambda d: pd.Series({
"MAE": d["MAE"].mean(),
"SMAPE": d["SMAPE"].mean(),
"WAPE": wape_micro(d["y_true"].values, d["y_pred"].values, eps=EPS),
"MASE1": d["MASE1"].mean(),
"MASE12": d["MASE12"].mean(),
"n_series_mes": int(d.loc[d["cobertura_mes"]==1, [PAIR_COLS[0],PAIR_COLS[1]]].drop_duplicates().shape[0]),
"peso_gasto_mes": float(np.nansum(np.abs(d["y_true"]))),
"cobertura_relativa_mes": float(d.loc[d["cobertura_mes"]==1, [PAIR_COLS[0],PAIR_COLS[1]]].drop_duplicates().shape[0]) / max(total_series_portfolio, 1),
})).reset_index(drop=True)
# Anual (fila agregada 2024)
mask_valid = series_month["cobertura_mes"]==1
d_all = series_month.loc[mask_valid].copy()
annual_row = pd.DataFrame([{
"FECHA": pd.Timestamp("2024-12-01"),
"MAE_2024": d_all["MAE"].mean(),
"SMAPE_2024": d_all["SMAPE"].mean(),
"WAPE_2024": wape_micro(d_all["y_true"].values, d_all["y_pred"].values, eps=EPS),
"MASE1_2024": d_all["MASE1"].mean(),
"MASE12_2024": d_all["MASE12"].mean(),
"n_series_activas_2024": int(d_all[[*PAIR_COLS]].drop_duplicates().shape[0]),
"peso_gasto_2024": float(np.nansum(np.abs(d_all["y_true"]))),
"cobertura_relativa_media": float(portfolio_month["cobertura_relativa_mes"].mean()) if len(portfolio_month)>0 else np.nan
}])
# Unimos y guardamos
portfolio_out = portfolio_month.copy()
for col in annual_row.columns:
if col not in portfolio_out.columns:
portfolio_out[col] = np.nan
portfolio_out = pd.concat([portfolio_out, annual_row], ignore_index=True)
portfolio_out.to_csv(OUT_PORTFOLIO, sep=CSV_SEP, index=False)
log9(f"Guardado: {OUT_PORTFOLIO} con {len(portfolio_out)} filas.")
[2025-09-24T12:37:12] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_portfolio_mensual.csv con 13 filas.
Hemos generado metrics_portfolio_mensual.csv con 13 filas:
12 filas mensuales (enero–diciembre 2024).
1 fila agregada anual (2024-12-01) con los promedios globales (MAE_2024, SMAPE_2024, WAPE_2024, etc.).
Bloque 8 - Listados de apoyo a la acción¶
# ============================================================
# Bloque 8) Listados de apoyo a la acción
# ============================================================
# Top-10 series por contribución al error absoluto anual
contrib = (series_month
.groupby(PAIR_COLS, as_index=False)
.agg(contrib_abs_error_2024=("abs_err","sum"),
peso_gasto_2024=("peso_gasto_mes","sum")))
# Intentamos añadir 'route' si venía en preds (opcional)
if "route" in preds_df.columns:
route_pairs = preds_df[PAIR_COLS + ["route"]].drop_duplicates()
contrib = contrib.merge(route_pairs, on=PAIR_COLS, how="left")
top10_series = contrib.sort_values("contrib_abs_error_2024", ascending=False).head(10)
top10_series.to_csv(OUT_TOP10, sep=CSV_SEP, index=False)
log9(f"Guardado: {OUT_TOP10}")
# Top-5 segmentos “rojos” por WAPE 2024 (al menos por FM_COST_TYPE)
top5_frames = []
for dim_col, dim_name in [("FM_COST_TYPE","FM_COST_TYPE"), ("PAIS","PAIS"), ("REGION","REGION"), ("TIPO_USO","TIPO_USO")]:
if (dim_col == "FM_COST_TYPE") or (dim_col in series_month.columns):
df_dim = series_month.copy()
if dim_col != "FM_COST_TYPE":
df_dim = df_dim.dropna(subset=[dim_col])
grp = df_dim.groupby(dim_col, as_index=False).apply(
lambda d: pd.Series({
"WAPE_2024": wape_micro(d["y_true"].values, d["y_pred"].values, eps=EPS),
"peso_gasto_2024": float(np.nansum(np.abs(d["y_true"]))),
"n_series": int(d[[*PAIR_COLS]].drop_duplicates().shape[0])
})
).reset_index(drop=True)
if grp.empty:
continue
grp = grp.rename(columns={dim_col: "clave_segmento"})
grp["nivel_segmento"] = dim_name
grp = grp.sort_values("WAPE_2024", ascending=False).head(5)
grp["ranking"] = np.arange(1, len(grp)+1)
top5_frames.append(grp)
top5_out = pd.concat(top5_frames, ignore_index=True) if len(top5_frames)>0 else pd.DataFrame(
columns=["nivel_segmento","clave_segmento","WAPE_2024","peso_gasto_2024","n_series","ranking"]
)
top5_out.to_csv(OUT_TOP5, sep=CSV_SEP, index=False)
log9(f"Guardado: {OUT_TOP5}")
[2025-09-24T12:37:12] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/top10_series_contrib_error.csv [2025-09-24T12:37:12] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/top5_segmentos_rojos_WAPE.csv
Ahora disponemos de dos listados clave para la acción operativa y de dirección de FerMar S.L.:
top10_series_contrib_error.csv
Son las 10 series individuales que más aportan al error absoluto acumulado de 2024.
Nos sirven para focalizar revisiones o ajustes en pocos puntos críticos que explican gran parte del error global.
top5_segmentos_rojos_WAPE.csv
Identifica los 5 segmentos con mayor WAPE 2024 en cada dimensión (tipo de coste, país, región, uso).
Muestra también el peso en gasto y el número de series, de modo que puedes distinguir si un WAPE alto es realmente estratégico (por peso) o solo anecdótico.
A nivel de reporting, estos dos outputs son lo que negocio/dirección pueden consumir de forma muy rápida:
Top10 series -> dónde poner foco inmediato.
Top5 segmentos -> qué áreas estructurales son más débiles.
Bloque 9 - Subset estable y agregados específicos¶
# ============================================================
# Bloque 9) Subset estable y agregados específicos
# ============================================================
# Identificamos series con 12/12 en 2024
cov_2024 = (series_month[series_month["cobertura_mes"]==1]
.groupby(PAIR_COLS, as_index=False)[DATECOL].nunique()
.rename(columns={DATECOL:"n_meses_2024"}))
stable_12m = cov_2024[cov_2024["n_meses_2024"]==12][PAIR_COLS]
# Comprobamos histórico con >=36 meses y sin huecos (2021-01..2023-12 => 36 MS)
train_ms = _ensure_ms(train_df, DATECOL)
train_panel = (train_ms[(train_ms[DATECOL] >= "2021-01-01") & (train_ms[DATECOL] <= "2023-12-01")]
.groupby(PAIR_COLS, as_index=False)[DATECOL].nunique()
.rename(columns={DATECOL:"n_train_ms"}))
stable_hist = train_panel[train_panel["n_train_ms"]==36][PAIR_COLS]
# Subset estable = intersección
stable_pairs = stable_12m.merge(stable_hist, on=PAIR_COLS, how="inner")
log9(f"Subset estable: {stable_pairs.shape[0]} series.")
subset_df = series_month.merge(stable_pairs, on=PAIR_COLS, how="inner")
# Métricas de portfolio del subset
g = subset_df.groupby(DATECOL, as_index=False)
subset_portfolio_month = g.apply(lambda d: pd.Series({
"MAE": d["MAE"].mean(),
"SMAPE": d["SMAPE"].mean(),
"WAPE": wape_micro(d["y_true"].values, d["y_pred"].values, eps=EPS),
"MASE1": d["MASE1"].mean(),
"MASE12": d["MASE12"].mean(),
"n_series_mes": int(d.loc[d["cobertura_mes"]==1, [*PAIR_COLS]].drop_duplicates().shape[0]),
"peso_gasto_mes": float(np.nansum(np.abs(d["y_true"]))),
})).reset_index(drop=True)
subset_portfolio_month["scope"] = "PORTFOLIO"
# Agregados por segmento del subset (reutilizamos función del bloque 6)
subset_seg_frames = []
for dim_col, dim_name in [("FM_COST_TYPE","FM_COST_TYPE"), ("PAIS","PAIS"), ("REGION","REGION"), ("TIPO_USO","TIPO_USO")]:
if (dim_col == "FM_COST_TYPE") or (dim_col in subset_df.columns):
uni = _universe_by_dim(stable_pairs, subset_df, dim_col)
seg_df = _agg_segment_month(subset_df, dim_col, dim_name, uni)
seg_df["scope"] = "SEGMENTO"
subset_seg_frames.append(seg_df)
subset_seg_month = (pd.concat(subset_seg_frames, ignore_index=True)
if len(subset_seg_frames)>0 else
pd.DataFrame(columns=["nivel_segmento","clave_segmento",DATECOL,"MAE","SMAPE","WAPE","MASE1","MASE12",
"n_series_mes","peso_gasto_mes","cobertura_relativa","scope"]))
# Guardamos en un único CSV con una columna 'scope' para distinguir vistas
# Para minimizar fricción, apilamos: primero portfolio, luego segmentos
subset_portfolio_month.to_csv(OUT_SUBSET, sep=CSV_SEP, index=False)
with open(OUT_SUBSET, "a", encoding="utf-8") as f:
subset_seg_month.to_csv(f, sep=CSV_SEP, index=False, header=False)
log9(f"Guardado: {OUT_SUBSET}")
[2025-09-24T12:37:12] Subset estable: 2231 series. [2025-09-24T12:37:16] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable.csv
Sí nos interesa entrar al detalle en dónde están los errores, ahora podemos analizar estos dos csv, ambos nos permiten detectar patrones, anomalías o debilidades del modelo.
Es el material con el que luego podemos orientar al equipo de FM operativo:
“Focalicemonos en estas 10 series y estos 5 segmentos, porque concentran el mayor error. Si entendemos qué ocurre ahí, el resto del portfolio mejorará de rebote.”
Bloque 10 - Control F: WAPE anual del portfolio vs baseline¶
# ============================================================
# Bloque 10) Control F: WAPE anual del portfolio vs baseline
# ============================================================
def _controlF_with_external_baseline(portfolio_out_csv: str, external_baseline_path: str | None):
# Intentamos leer un baseline externo si se proporciona
if external_baseline_path and os.path.exists(external_baseline_path):
bdf = pd.read_csv(external_baseline_path, sep=CSV_SEP)
# Intentamos localizar una columna razonable para el WAPE baseline anual
cand_cols = [c for c in bdf.columns if "WAPE" in c.upper() and "BASELINE" in c.upper()]
if len(cand_cols)==0 and "WAPE_portfolio_baseline" in bdf.columns:
cand_cols = ["WAPE_portfolio_baseline"]
if len(cand_cols)>0:
# Tomamos el primer candidato
baseline_val = float(pd.to_numeric(bdf[cand_cols[0]], errors="coerce").dropna().iloc[-1])
return baseline_val
log9("AVISO: no identificamos columna de baseline en el CSV externo; pasamos a SNaive12.")
else:
if external_baseline_path:
log9(f"AVISO: baseline externo no encontrado en {external_baseline_path}; usamos SNaive12.")
return None
def _controlF_compute_snaive12_baseline(train_df: pd.DataFrame,
test_2024: pd.DataFrame,
panel_pairs: pd.DataFrame) -> float:
"""
Recalcula baseline SNaive12 en el mismo universo que se evalúa:
- Solo series del panel (modelo_final).
- Solo series con cobertura en 2024 (al menos un y_true no nulo).
Devuelve el WAPE micro del portfolio para esas series.
"""
# 1) Universo: pares del panel con cobertura en 2024
base_eval = (panel_pairs
.merge(test_2024.rename(columns={VALUE_TRAINTEST: "y_true"}),
on=PAIR_COLS, how="left"))
base_eval = base_eval[base_eval["y_true"].notna()] # cobertura
pares_cubiertos = base_eval[PAIR_COLS].drop_duplicates()
if pares_cubiertos.empty:
log9("[ControlF] AVISO: no hay pares con cobertura en 2024 para baseline SNaive12.")
return np.nan
# 2) Recalcular SNaive12 por serie (12 meses de 2024)
train_idx = (_ensure_ms(train_df, DATECOL)
.sort_values(PAIR_COLS + [DATECOL])
.set_index(PAIR_COLS))
preds_list = []
for key, g in train_idx.groupby(level=[0, 1]):
# Solo series que realmente vamos a evaluar
if not ((pares_cubiertos[PAIR_COLS[0]] == key[0]) &
(pares_cubiertos[PAIR_COLS[1]] == key[1])).any():
continue
y_hist = g[VALUE_TRAINTEST].astype(float).values
d_hist = g[DATECOL].values
yhat = snaive12_forecast(y_hist, d_hist, horizon=12)
tmp = pd.DataFrame({
PAIR_COLS[0]: [key[0]] * len(MONTHS_2024),
PAIR_COLS[1]: [key[1]] * len(MONTHS_2024),
DATECOL: MONTHS_2024,
"yhat_snaive12": yhat
})
preds_list.append(tmp)
if len(preds_list) == 0:
log9("[ControlF] AVISO: no fue posible generar predicciones SNaive12 para ningún par del panel con cobertura.")
return np.nan
snaive_preds = pd.concat(preds_list, ignore_index=True)
# 3) Ensamblar base de comparación y restringir a las filas con y_true
base = (test_2024.rename(columns={VALUE_TRAINTEST: "y_true"})
.merge(snaive_preds, on=PAIR_COLS + [DATECOL], how="inner"))
base = base[base["y_true"].notna()]
if base.empty:
log9("[ControlF] AVISO: tras el merge, no quedan observaciones con y_true para evaluar baseline.")
return np.nan
# 4) WAPE micro del portfolio (baseline)
return wape_micro(base["y_true"].values, base["yhat_snaive12"].values, eps=EPS)
# Leemos nuestro WAPE anual del portfolio ya calculado
WAPE_modelo_final = float(portfolio_out.loc[portfolio_out["WAPE"].notna(), "WAPE"].iloc[:-1].mean()) if "WAPE" in portfolio_out.columns else np.nan
# Para el valor anual, preferimos el campo "WAPE_2024" si está
if "WAPE_2024" in portfolio_out.columns and pd.notna(portfolio_out["WAPE_2024"]).any():
WAPE_modelo_final = float(portfolio_out["WAPE_2024"].dropna().iloc[-1])
# Intentamos baseline externo; si no, recalculamos SNaive12 con cobertura adecuada
baseline_external = _controlF_with_external_baseline(OUT_PORTFOLIO, external_baseline_path=None)
if baseline_external is None:
WAPE_baseline = _controlF_compute_snaive12_baseline(train_df, test_2024, panel_pairs)
baseline_label = "SNaive12 (solo pares con cobertura 2024)"
else:
WAPE_baseline = baseline_external
baseline_label = "baseline externo"
ok_controlF = (WAPE_modelo_final <= WAPE_baseline) if (pd.notna(WAPE_modelo_final) and pd.notna(WAPE_baseline)) else False
log9(f"Control F — WAPE_portfolio_modelo_final={WAPE_modelo_final:.6f}")
log9(f"Control F — WAPE_portfolio_baseline ({baseline_label})={WAPE_baseline:.6f}")
log9(f"Control F — ok_controlF={ok_controlF}")
if not ok_controlF:
log9("ADVERTENCIA Control F: el modelo final no mejora al baseline. Checklist rápido:")
log9(" - ¿TSB activo en intermitentes?")
log9(" - ¿Combo top-2 aplicado (weighted_by_inv_MASE12 o mean50_50)?")
log9(" - ¿Fallback SNaive12 aplicado donde tocaba (solo test / fallos)?")
[2025-09-24T12:37:29] Control F — WAPE_portfolio_modelo_final=0.358801 [2025-09-24T12:37:29] Control F — WAPE_portfolio_baseline (SNaive12 (solo pares con cobertura 2024))=0.392322 [2025-09-24T12:37:29] Control F — ok_controlF=True
Interpretación
WAPE_portfolio_modelo_final = 0.359 (~35,9%): El error porcentual ponderado de las predicciones del modelo final es ~36%.
WAPE_portfolio_baseline = 0.392 (~39,2%): El error si usáramos la estrategia de referencia (SNaive12) sería mayor.
ok_controlF = True: Esto confirma que el modelo sí supera el baseline, cumpliendo el criterio de validación que se había definido en el proyecto.
Qué significa en la práctica
Para Dirección:
El sistema de predicción ya es más preciso que el método tradicional (baseline) en términos globales.
Pueden confiar en que el uso del modelo reducirá las desviaciones presupuestarias a nivel portfolio.
Para Consultores/Equipo FM:
Aunque hay segmentos y series con errores altos (vimos en Bloque 8 los focos rojos), globalmente el modelo aporta valor añadido.
El siguiente paso es decidir cómo trasladar esto al equipo de FM: “El modelo mejora la predicción global, pero necesitamos trabajar con vosotros en estas áreas concretas donde aún fallamos más.”
Bloque 11 - Resumen final¶
# ============================================================
# Bloque 11) Resumen final
# ============================================================
try:
wape_anual = float(portfolio_out["WAPE_2024"].dropna().iloc[-1])
mae_anual = float(portfolio_out["MAE_2024"].dropna().iloc[-1])
smape_anual= float(portfolio_out["SMAPE_2024"].dropna().iloc[-1])
log9(f"Resumen — Portfolio 2024: WAPE={wape_anual:.6f}, MAE={mae_anual:.6f}, SMAPE={smape_anual:.2f}%")
except Exception:
log9("Resumen — No fue posible imprimir métricas anuales del portfolio (faltan columnas).")
log9("Paso 9 finalizado.")
[2025-09-24T12:37:29] Resumen — Portfolio 2024: WAPE=0.358801, MAE=539.231234, SMAPE=78.77% [2025-09-24T12:37:29] Paso 9 finalizado.
Paso 10 - Conciliación jerárquica ligera (MinT shrink) sobre previsiones 2024 con reales 2024 (solo para desarrollo)¶
Bloque 0 — Parámetros y rutas¶
# ============================================================
# ESTRATEGIA 3 — PASO 10
# Reconciliación jerárquica ligera (MinT shrink) sobre 2024
# ============================================================
# En este paso:
# - Leemos predicciones base 2024 por serie (bottom)
# - Construimos un árbol jerárquico mínimo:
# Bottom: (ID_BUILDING, FM_COST_TYPE)
# Nivel 1: (FM_COST_TYPE, PAIS) si PAIS existe
# Nivel 2 (opcional): (FM_COST_TYPE, REGION) si REGION existe
# Top (opcional): (FM_COST_TYPE)
# - Reconciliamos mes a mes mediante MinT (shrink simple) en forma WLS con restricciones:
# y_b_rec = y_b + W_b A' (A W_b A')^{-1} (c - A y_b)
# A es (n_agg x n_bottom); W_b diagonal (varianzas bottom)
# c se toma de la verdad 2024 agregada cuando existe; si no, c = A y_b
# - Clippamos a >= 0 solo en bottom
# - Guardamos predicciones reconciliadas y comparamos métricas contra base
# - Punto de control G: si la mejora en WAPE anual (portfolio) < 1 pp, recomendamos mantener base
# ============================================================
# -----------------------------
# Bloque 0 — Parámetros y rutas
# -----------------------------
np.random.seed(7)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
os.makedirs(RUTA_RESULTADOS, exist_ok=True)
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_REPORTING, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
# Entradas
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv") # del Paso 5
PATH_TEST = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv") # verdad terreno 2024
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # del Paso 9
PATH_PASO8 = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv") # opcional
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv") # opcional (escalas MASE)
# Salidas
OUT_PREDS_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv")
OUT_COMPARE = os.path.join(RUTA_REPORTING, "compare_metrics_reconc_vs_base.csv")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step10")
os.makedirs(FIGS_DIR, exist_ok=True)
# Fechas / claves
PAIR_COLS = ["ID_BUILDING", "FM_COST_TYPE"]
DATECOL = "FECHA"
VALCOL_TRUE = "cost_float_mod"
VALCOL_PRED = "yhat_combo"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq="MS")
# Logging
LOG_PATH = os.path.join(RUTA_LOGS, "estrategia3_step10.log")
def log10(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
log10("Iniciamos Paso 10 — Reconciliación jerárquica MinT (shrink).")
[2025-09-24T12:37:30] Iniciamos Paso 10 — Reconciliación jerárquica MinT (shrink).
Bloque 1 — Utilidades generales/IO¶
# -------------------------------------
# Bloque 1 — Utilidades generales/IO
# -------------------------------------
EPS = 1e-8
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _read_csv(path: str, required: bool = True) -> pd.DataFrame:
if os.path.exists(path):
df = pd.read_csv(path, sep=CSV_SEP)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta archivo requerido: {path}")
log10(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _norm_pair_keys(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty:
return df
out = df.copy()
out.columns = [str(c).strip() for c in out.columns]
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
if "FM_COST_TYPE" in out.columns:
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
def _safe_div(num, den, eps=EPS):
return num / (den + eps)
def wape_micro(y_true, y_pred, eps=EPS):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(_safe_div(np.sum(np.abs(y_pred - y_true)), np.sum(np.abs(y_true)), eps))
def smape_vec(y_true, y_pred, eps=EPS):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
num = np.abs(y_pred - y_true)
den = (np.abs(y_true) + np.abs(y_pred)) / 2.0
return float(np.mean(_safe_div(num, den, eps)) * 100.0)
def _scale_diff(series: np.ndarray, lag: int):
arr = np.asarray(series, dtype=float)
if len(arr) <= lag:
return np.nan
diffs = np.abs(arr[lag:] - arr[:-lag])
if diffs.size == 0:
return np.nan
return float(np.mean(diffs))
def mase_from_scale(errors: np.ndarray, scale: float):
if scale is None or np.isnan(scale) or scale == 0:
return np.nan
errors = np.asarray(errors, dtype=float)
return float(np.mean(np.abs(errors)) / float(scale))
Bloque 2 — Carga y preparación de las entradas¶
# ------------------------------------------------
# Bloque 2 — Carga y preparación de las entradas
# ------------------------------------------------
preds_df = _ensure_ms(_read_csv(PATH_PREDS, required=True), DATECOL)
preds_df = _norm_pair_keys(preds_df)
if VALCOL_PRED not in preds_df.columns:
raise ValueError(f"Esperábamos columna {VALCOL_PRED} en {os.path.basename(PATH_PREDS)}.")
# Nos quedamos con las columnas mínimas
keep_pred_cols = [c for c in [*PAIR_COLS, DATECOL, VALCOL_PRED, "route", "fallback_flag"] if c in preds_df.columns]
preds_df = preds_df[keep_pred_cols].copy()
# Aseguramos que tenemos una fila por (pair, mes); si hay duplicados, agregamos sum
preds_df = (preds_df.groupby(PAIR_COLS + [DATECOL], as_index=False)
.agg({VALCOL_PRED: "sum", **({"route":"last"} if "route" in preds_df.columns else {}),
**({"fallback_flag":"max"} if "fallback_flag" in preds_df.columns else {})}))
# Verdad terreno 2024 (para métricas y, si queremos, para c)
test_df = _ensure_ms(_read_csv(PATH_TEST, required=True), DATECOL)
test_df = _norm_pair_keys(test_df)
test_2024 = test_df[[*PAIR_COLS, DATECOL, VALCOL_TRUE]].copy()
# Reindexamos a malla 2024 (dejamos NaN donde no haya cobertura)
g = (test_2024.groupby(PAIR_COLS + [pd.Grouper(key=DATECOL, freq="MS")], as_index=False)[VALCOL_TRUE].sum())
panel_pairs = preds_df[PAIR_COLS].drop_duplicates()
reindexed = []
for a,b in panel_pairs.values:
sub = g[(g[PAIR_COLS[0]]==a) & (g[PAIR_COLS[1]]==b)].set_index(DATECOL)
sub = sub.reindex(MONTHS_2024).rename_axis(DATECOL).reset_index()
sub[PAIR_COLS[0]] = a; sub[PAIR_COLS[1]] = b
reindexed.append(sub)
test_2024 = pd.concat(reindexed, ignore_index=True)
# Dimensiones (opcional PAIS/REGION)
dim_df = _read_csv(PATH_DIM, required=False)
if not dim_df.empty:
dim_cols_map = {}
for c in dim_df.columns:
cu = c.strip().upper()
if cu == "PAIS": dim_cols_map[c] = "PAIS"
elif cu == "REGION": dim_cols_map[c] = "REGION"
elif cu == "ID_BUILDING": dim_cols_map[c] = "ID_BUILDING"
dim_df = dim_df.rename(columns=dim_cols_map)
dim_df = _norm_pair_keys(dim_df)
dim_df = dim_df[[c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim_df.columns]].drop_duplicates()
else:
dim_df = pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION"])
# Paso 8 (opcional) para proxy de W si no hay verdad
paso8_df = _read_csv(PATH_PASO8, required=False)
# Train (opcional) para escalas MASE
train_df = _read_csv(PATH_TRAIN, required=False)
if not train_df.empty:
train_df = _ensure_ms(_norm_pair_keys(train_df), DATECOL)
log10(f"Cargadas entradas: preds={len(preds_df)} filas, test_2024={len(test_2024)} filas, dim={len(dim_df)} filas.")
log10(f"scipy.sparse disponible: {SCIPY_AVAILABLE}")
[2025-09-24T12:37:44] Cargadas entradas: preds=29148 filas, test_2024=29148 filas, dim=1650 filas. [2025-09-24T12:37:44] scipy.sparse disponible: True
Bloque 3 — Preparación del panel y metadatos jerárquicos¶
# ------------------------------------------------------------
# Bloque 3 — Preparación del panel y metadatos jerárquicos
# ------------------------------------------------------------
# Unimos PAIS/REGION a nivel bottom
bottom_df = preds_df.merge(dim_df, on="ID_BUILDING", how="left")
n_bottom = bottom_df[PAIR_COLS].drop_duplicates().shape[0]
# Diagnóstico de metadatos
pct_no_pais = 1.0 - float(bottom_df["PAIS"].notna().mean()) if "PAIS" in bottom_df.columns else 1.0
pct_no_region = 1.0 - float(bottom_df["REGION"].notna().mean()) if "REGION" in bottom_df.columns else 1.0
log10(f"Metadatos: n_bottom={n_bottom} | % sin PAIS={100*pct_no_pais:.1f}% | % sin REGION={100*pct_no_region:.1f}%")
# Construimos claves agregadas presentes en el bottom real
def _unique_keys(df, cols):
cols = [c for c in cols if c in df.columns]
if len(cols)==0:
return pd.DataFrame(columns=cols)
u = df[cols].dropna().drop_duplicates()
return u
keys_top = _unique_keys(bottom_df, ["FM_COST_TYPE"])
keys_fm_pais = _unique_keys(bottom_df, ["FM_COST_TYPE","PAIS"])
keys_fm_region = _unique_keys(bottom_df, ["FM_COST_TYPE","REGION"])
# Avisamos si alguna capa queda vacía
if keys_top.empty:
log10("AVISO: capa TOP (FM_COST_TYPE) vacía; continuamos sin top.")
if "PAIS" not in bottom_df.columns or keys_fm_pais.empty:
log10("AVISO: capa (FM_COST_TYPE, PAIS) vacía o sin metadatos; continuamos sin esa capa.")
if "REGION" not in bottom_df.columns or keys_fm_region.empty:
log10("AVISO: capa (FM_COST_TYPE, REGION) vacía o sin metadatos; continuamos sin esa capa.")
# Orden estable de claves
def _sorted_df(df, cols):
return df.sort_values(cols).reset_index(drop=True)
keys_top = _sorted_df(keys_top, ["FM_COST_TYPE"]) if not keys_top.empty else keys_top
keys_fm_pais = _sorted_df(keys_fm_pais, ["FM_COST_TYPE","PAIS"]) if not keys_fm_pais.empty else keys_fm_pais
keys_fm_region = _sorted_df(keys_fm_region, ["FM_COST_TYPE","REGION"]) if not keys_fm_region.empty else keys_fm_region
keys_bottom = _sorted_df(bottom_df[PAIR_COLS].drop_duplicates(), PAIR_COLS)
# Índice bottom y helpers de pertenencia
bottom_index = {tuple(r): i for i, r in keys_bottom.itertuples(index=False, name=None)}
def _child_idx_for_top(fm):
mask = (keys_bottom["FM_COST_TYPE"]==fm)
return keys_bottom.index[mask].tolist()
def _child_idx_for_fm_pais(fm, pais):
mask = (keys_bottom["FM_COST_TYPE"]==fm)
if "PAIS" not in bottom_df.columns:
return []
sub = bottom_df.merge(keys_bottom[mask][PAIR_COLS], on=PAIR_COLS, how="inner")
sub = sub[sub["PAIS"]==pais]
idx = keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
return idx
def _child_idx_for_fm_region(fm, region):
mask = (keys_bottom["FM_COST_TYPE"]==fm)
if "REGION" not in bottom_df.columns:
return []
sub = bottom_df.merge(keys_bottom[mask][PAIR_COLS], on=PAIR_COLS, how="inner")
sub = sub[sub["REGION"]==region]
idx = keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
return idx
# Reportamos tamaños por nivel
n_top = 0 if keys_top.empty else len(keys_top)
n_fm_pais = 0 if keys_fm_pais.empty else len(keys_fm_pais)
n_fm_region = 0 if keys_fm_region.empty else len(keys_fm_region)
total_nodes = n_top + n_fm_pais + n_fm_region + n_bottom
log10(f"Árbol: top={n_top} | fm×pais={n_fm_pais} | fm×region={n_fm_region} | bottom={n_bottom} | nodos totales={total_nodes}")
[2025-09-24T12:37:45] Metadatos: n_bottom=2429 | % sin PAIS=0.0% | % sin REGION=0.0% [2025-09-24T12:37:45] Árbol: top=8 | fm×pais=49 | fm×region=573 | bottom=2429 | nodos totales=3059
Bloque 4 — Construcción de S (diag) y de A (solo agregados, sin filas bottom)¶
# ---------------------------------------------
# Bloque 4 — Construcción de S (diag) y de A (solo agregados, sin filas bottom)
# ---------------------------------------------
def _build_S_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region, keys_bottom):
rows, cols, data = [], [], []
row_id = 0
if not keys_top.empty:
for (fm,) in keys_top.itertuples(index=False, name=None):
idxs = _child_idx_for_top(fm)
for j in idxs:
rows.append(row_id); cols.append(j); data.append(1.0)
row_id += 1
if not keys_fm_pais.empty:
for (fm, pais) in keys_fm_pais.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_pais(fm, pais)
for j in idxs:
rows.append(row_id); cols.append(j); data.append(1.0)
row_id += 1
if not keys_fm_region.empty:
for (fm, region) in keys_fm_region.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_region(fm, region)
for j in idxs:
rows.append(row_id); cols.append(j); data.append(1.0)
row_id += 1
start_bottom_row = row_id
for j in range(n_bottom):
rows.append(row_id); cols.append(j); data.append(1.0)
row_id += 1
m = row_id
if SCIPY_AVAILABLE:
S = sp.coo_matrix((data, (rows, cols)), shape=(m, n_bottom)).tocsr()
else:
S = np.zeros((m, n_bottom), dtype=float)
for r, c, v in zip(rows, cols, data):
S[r, c] = v
return S, start_bottom_row
# S lo dejamos como diagnóstico
S, start_bottom_row = _build_S_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region, keys_bottom)
if SCIPY_AVAILABLE:
log10(f"Matriz S: shape={S.shape}, nnz={S.nnz}")
else:
log10(f"Matriz S: shape={S.shape} (numpy densa)")
# A (solo agregados, sin filas bottom)
def _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region):
agg_rows = []
def _add_row(child_idx_list):
row = np.zeros(n_bottom, dtype=float)
if len(child_idx_list) > 0:
row[np.asarray(child_idx_list, dtype=int)] = 1.0
agg_rows.append(row)
# TOP
if not keys_top.empty:
for (fm,) in keys_top.itertuples(index=False, name=None):
idxs = _child_idx_for_top(fm)
_add_row(idxs)
# FM×PAIS
if not keys_fm_pais.empty:
for (fm, pais) in keys_fm_pais.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_pais(fm, pais)
_add_row(idxs)
# FM×REGION
if not keys_fm_region.empty:
for (fm, region) in keys_fm_region.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_region(fm, region)
_add_row(idxs)
if len(agg_rows) == 0:
A = np.zeros((0, n_bottom), dtype=float)
else:
A = np.vstack(agg_rows)
return A
A = _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region)
if SCIPY_AVAILABLE and A.size > 0:
A_sparse = sp.csr_matrix(A)
else:
A_sparse = None
log10(f"Matriz A (solo agregados): shape={A.shape}")
[2025-09-24T12:37:53] Matriz S: shape=(3059, 2429), nnz=9716 [2025-09-24T12:38:02] Matriz A (solo agregados): shape=(630, 2429)
Bloque 5 — Estimación de W (diagonal) para bottom¶
# ----------------------------------------------------------
# Bloque 5 — Estimación de W (diagonal) para bottom
# ----------------------------------------------------------
# Errores 2024 bottom por serie para varianza: e = yhat_base - y_true
preds_bottom = preds_df.merge(keys_bottom, on=PAIR_COLS, how="inner")
preds_bottom = preds_bottom[[*PAIR_COLS, DATECOL, VALCOL_PRED]].copy()
preds_bottom = preds_bottom.set_index(PAIR_COLS + [DATECOL])[VALCOL_PRED].rename("yhat_base").reset_index()
truth = test_2024.set_index(PAIR_COLS + [DATECOL])[VALCOL_TRUE].rename("y_true").reset_index()
base_eval = preds_bottom.merge(truth, on=PAIR_COLS + [DATECOL], how="left")
var_err = {}
for (bid, ctype), g in base_eval.groupby(PAIR_COLS, as_index=False):
e = (g["yhat_base"].values - g["y_true"].values) if g["y_true"].notna().any() else np.array([])
if e.size > 0:
v = float(np.nanvar(e, ddof=1)) if np.sum(~np.isnan(e)) >= 2 else float(np.nanvar(e))
if not np.isfinite(v) or v <= 0:
v = 1e-6
else:
v = np.nan
var_err[(bid, ctype)] = v
proxy_from_paso8 = {}
if os.path.exists(PATH_PASO8):
paso8 = _read_csv(PATH_PASO8, required=False)
if not paso8.empty:
paso8.columns = [str(c).strip() for c in paso8.columns]
if all(c in paso8.columns for c in PAIR_COLS) and "MASE12" in paso8.columns:
if "vista" in paso8.columns:
paso8 = paso8[paso8["vista"].astype(str).str.lower().eq("all_months")]
paso8 = paso8[[*PAIR_COLS, "MASE12"]].drop_duplicates(PAIR_COLS)
for r in paso8.itertuples(index=False):
k = (getattr(r, PAIR_COLS[0]), getattr(r, PAIR_COLS[1]))
proxy_from_paso8[k] = float(pd.to_numeric(getattr(r, "MASE12"), errors="coerce"))
W_diag = np.zeros(n_bottom, dtype=float)
n_from_truth = 0
n_from_proxy = 0
for i, (bid, ctype) in enumerate(keys_bottom.itertuples(index=False, name=None)):
k = (bid, ctype)
v = var_err.get(k, np.nan)
if np.isfinite(v):
W_diag[i] = max(v, 1e-8)
n_from_truth += 1
else:
m = proxy_from_paso8.get(k, np.nan)
if np.isfinite(m):
W_diag[i] = max(m**2, 1e-8)
n_from_proxy += 1
else:
W_diag[i] = 1.0
W_inv_diag = 1.0 / np.maximum(W_diag, 1e-8)
log10(f"W diagonal: bottom={n_bottom} | de verdad_2024={n_from_truth} | de proxy_paso8={n_from_proxy} | resto=identity={n_bottom-n_from_truth-n_from_proxy}")
[2025-09-24T12:38:03] W diagonal: bottom=2429 | de verdad_2024=2231 | de proxy_paso8=0 | resto=identity=198
Bloque 6 — Reconciliación mensual (WLS con A y c)¶
# ----------------------------------------------------------------
# Bloque 6 — Reconciliación mensual (WLS con A y c)
# ----------------------------------------------------------------
def _month_slice_pred(month_ts):
sub = preds_df[preds_df[DATECOL].eq(month_ts)]
merged = keys_bottom.merge(sub, on=PAIR_COLS, how="left")
yb = merged[VALCOL_PRED].fillna(0.0).values.astype(float)
return yb
def _month_slice_truth(month_ts):
sub = test_2024[test_2024[DATECOL].eq(month_ts)]
merged = keys_bottom.merge(sub, on=PAIR_COLS, how="left")
yt = merged[VALCOL_TRUE].astype(float).values
return yt
def _c_from_truth_or_base(month_ts, y_b):
if A.shape[0] == 0:
return np.zeros(0, dtype=float)
y_true_b = _month_slice_truth(month_ts)
if np.all(np.isnan(y_true_b)):
return A @ y_b
return A @ np.nan_to_num(y_true_b, nan=0.0)
def _mint_reconcile_for_month_bottomW(y_b, month_ts):
m = n_bottom
if m == 0 or A.shape[0] == 0:
y_b_rec = np.maximum(y_b, 0.0)
return y_b_rec
# c: preferimos verdad agregada; si no hay, A y_b
c = _c_from_truth_or_base(month_ts, y_b)
# A W A' con W diagonal
if SCIPY_AVAILABLE and A_sparse is not None and A.shape[0] > 0:
from scipy.sparse import diags, csr_matrix
Wb = diags(W_diag, offsets=0, format="csr") # (m x m)
AW = (A_sparse @ Wb) # (n_agg x m)
AWAT = (AW @ A_sparse.T).toarray() # (n_agg x n_agg) denso para invertir
else:
# denso
AW = A * W_diag # broadcast por columnas (n_agg x m)
AWAT = AW @ A.T
# Inversa estable de AWAT
try:
AWAT_inv = np.linalg.pinv(AWAT, rcond=1e-10)
except Exception:
AWAT_inv = np.linalg.pinv(AWAT + 1e-8*np.eye(AWAT.shape[0]))
# Residual en agregados
r = c - (A @ y_b) # (n_agg,)
# Delta = W A' (A W A')^{-1} r
v = AWAT_inv @ r # (n_agg,)
if SCIPY_AVAILABLE and A_sparse is not None and A.shape[0] > 0:
tmp = (A_sparse.T @ v) # (m, 1) o (m,)
tmp = np.asarray(tmp).ravel()
delta = W_diag * tmp # (m,)
else:
delta = ( (A.T * W_diag[:, None]) @ v ) # (m,)
y_b_rec = y_b + delta
# No negatividad en bottom
y_b_rec = np.maximum(y_b_rec, 0.0)
return y_b_rec
# Ejecutamos para los 12 meses
reconc_rows = []
for mth in MONTHS_2024:
yb = _month_slice_pred(mth)
yb_rec = _mint_reconcile_for_month_bottomW(yb, mth)
df_month = keys_bottom.copy()
df_month[DATECOL] = mth
df_month["yhat_base"] = yb
df_month["yhat_reconc"] = yb_rec
reconc_rows.append(df_month)
preds_reconc = pd.concat(reconc_rows, ignore_index=True)
preds_reconc = preds_reconc[[*PAIR_COLS, DATECOL, "yhat_reconc", "yhat_base"]].sort_values(PAIR_COLS + [DATECOL]).reset_index(drop=True)
preds_reconc.to_csv(OUT_PREDS_RECONC, sep=CSV_SEP, index=False)
log10(f"Guardado: {OUT_PREDS_RECONC} con {len(preds_reconc)} filas.")
[2025-09-24T12:38:06] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_2024.csv con 29148 filas.
Bloque 7 — Métricas y comparación base vs reconciliado¶
# -----------------------------------------------------------
# Bloque 7 — Métricas y comparación base vs reconciliado
# -----------------------------------------------------------
truth_all = test_2024[[*PAIR_COLS, DATECOL, VALCOL_TRUE]].rename(columns={VALCOL_TRUE:"y_true"}).copy()
base_all = preds_reconc[[*PAIR_COLS, DATECOL, "yhat_base"]].copy()
reco_all = preds_reconc[[*PAIR_COLS, DATECOL, "yhat_reconc"]].copy()
eval_df = truth_all.merge(base_all, on=PAIR_COLS+[DATECOL], how="left").merge(reco_all, on=PAIR_COLS+[DATECOL], how="left")
# Escalas MASE a partir de train si existe
scales = {}
if not train_df.empty and all(c in train_df.columns for c in [*PAIR_COLS, DATECOL, VALCOL_TRUE]):
tr = _ensure_ms(train_df, DATECOL).sort_values(PAIR_COLS + [DATECOL]).set_index(PAIR_COLS)
for key, g in tr.groupby(level=[0,1]):
y_hist = g[VALCOL_TRUE].astype(float).values
s1 = _scale_diff(y_hist, 1)
s12 = _scale_diff(y_hist, 12)
scales[key] = {"scale1": s1, "scale12": s12}
def _lookup_scales(row):
key = (row[PAIR_COLS[0]], row[PAIR_COLS[1]])
sc = scales.get(key, {"scale1": np.nan, "scale12": np.nan})
return pd.Series({"scale1": sc["scale1"], "scale12": sc["scale12"]})
if len(scales)>0:
eval_df = eval_df.merge(eval_df.apply(_lookup_scales, axis=1), left_index=True, right_index=True)
else:
eval_df["scale1"] = np.nan
eval_df["scale12"] = np.nan
mask = eval_df["y_true"].notna()
work = eval_df.loc[mask].copy()
work["abs_err_base"] = np.abs(work["yhat_base"] - work["y_true"])
work["abs_err_reconc"] = np.abs(work["yhat_reconc"] - work["y_true"])
def _smape_point(y, yhat, eps=EPS):
y = float(y) if pd.notna(y) else np.nan
yhat = float(yhat) if pd.notna(yhat) else np.nan
if np.isnan(y) or np.isnan(yhat):
return np.nan
den = max((abs(y) + abs(yhat))/2.0, eps)
return 100.0 * abs(yhat - y) / den
work["SMAPE_base"] = work.apply(lambda r: _smape_point(r["y_true"], r["yhat_base"]), axis=1)
work["SMAPE_reconc"] = work.apply(lambda r: _smape_point(r["y_true"], r["yhat_reconc"]), axis=1)
def _mase_point(abs_err, scale):
if scale is None or np.isnan(scale) or scale==0:
return np.nan
return float(abs_err / float(scale))
work["MASE12_base"] = work.apply(lambda r: _mase_point(r["abs_err_base"], r["scale12"]), axis=1)
work["MASE12_reconc"] = work.apply(lambda r: _mase_point(r["abs_err_reconc"], r["scale12"]), axis=1)
# ----------------------------
# Agregados de comparación
# ----------------------------
def _agg_portfolio_month(df):
out = []
for mth, g in df.groupby(DATECOL):
MAE_b = float(g["abs_err_base"].mean())
MAE_r = float(g["abs_err_reconc"].mean())
WAPE_b = wape_micro(g["y_true"].values, g["yhat_base"].values)
WAPE_r = wape_micro(g["y_true"].values, g["yhat_reconc"].values)
SM_b = float(g["SMAPE_base"].mean())
SM_r = float(g["SMAPE_reconc"].mean())
M12_b = float(g["MASE12_base"].mean())
M12_r = float(g["MASE12_reconc"].mean())
out.append({
"scope":"PORTFOLIO_MENSUAL","clave":None,DATECOL:mth,
"MAE_base":MAE_b,"MAE_reconc":MAE_r,"ΔMAE":MAE_r-MAE_b,
"WAPE_base":WAPE_b,"WAPE_reconc":WAPE_r,"ΔWAPE":WAPE_r-WAPE_b,
"SMAPE_base":SM_b,"SMAPE_reconc":SM_r,"ΔSMAPE":SM_r-SM_b,
"MASE12_base":M12_b,"MASE12_reconc":M12_r,"ΔMASE12":M12_r-M12_b
})
return pd.DataFrame(out)
def _agg_portfolio_annual(df):
MAE_b = float(df["abs_err_base"].mean())
MAE_r = float(df["abs_err_reconc"].mean())
WAPE_b = wape_micro(df["y_true"].values, df["yhat_base"].values)
WAPE_r = wape_micro(df["y_true"].values, df["yhat_reconc"].values)
SM_b = float(df["SMAPE_base"].mean())
SM_r = float(df["SMAPE_reconc"].mean())
M12_b = float(df["MASE12_base"].mean())
M12_r = float(df["MASE12_reconc"].mean())
return pd.DataFrame([{
"scope":"PORTFOLIO_ANUAL","clave":"2024",DATECOL:pd.Timestamp("2024-12-01"),
"MAE_base":MAE_b,"MAE_reconc":MAE_r,"ΔMAE":MAE_r-MAE_b,
"WAPE_base":WAPE_b,"WAPE_reconc":WAPE_r,"ΔWAPE":WAPE_r-WAPE_b,
"SMAPE_base":SM_b,"SMAPE_reconc":SM_r,"ΔSMAPE":SM_r-SM_b,
"MASE12_base":M12_b,"MASE12_reconc":M12_r,"ΔMASE12":M12_r-M12_b
}])
def _agg_segment(df, group_cols, scope_name):
out = []
df2 = df.dropna(subset=[c for c in group_cols if c!="FM_COST_TYPE"], how="any") if len(group_cols)>1 else df
if df2.empty:
return pd.DataFrame(columns=["scope","clave",DATECOL,"MAE_base","MAE_reconc","ΔMAE",
"WAPE_base","WAPE_reconc","ΔWAPE",
"SMAPE_base","SMAPE_reconc","ΔSMAPE",
"MASE12_base","MASE12_reconc","ΔMASE12"])
for keys, g in df2.groupby(group_cols + [DATECOL]):
*seg, mth = keys
MAE_b = float(g["abs_err_base"].mean())
MAE_r = float(g["abs_err_reconc"].mean())
WAPE_b = wape_micro(g["y_true"].values, g["yhat_base"].values)
WAPE_r = wape_micro(g["y_true"].values, g["yhat_reconc"].values)
SM_b = float(g["SMAPE_base"].mean())
SM_r = float(g["SMAPE_reconc"].mean())
M12_b = float(g["MASE12_base"].mean())
M12_r = float(g["MASE12_reconc"].mean())
clave = "|".join(map(str, seg))
out.append({
"scope":scope_name,"clave":clave,DATECOL:mth,
"MAE_base":MAE_b,"MAE_reconc":MAE_r,"ΔMAE":MAE_r-MAE_b,
"WAPE_base":WAPE_b,"WAPE_reconc":WAPE_r,"ΔWAPE":WAPE_r-WAPE_b,
"SMAPE_base":SM_b,"SMAPE_reconc":SM_r,"ΔSMAPE":SM_r-SM_b,
"MASE12_base":M12_b,"MASE12_reconc":M12_r,"ΔMASE12":M12_r-M12_b
})
return pd.DataFrame(out)
# Enriquecemos work con PAIS/REGION para agregados de segmento
work = work.merge(dim_df, on="ID_BUILDING", how="left")
# Portfolio mensual y anual
cmp_port_month = _agg_portfolio_month(work)
cmp_port_annual= _agg_portfolio_annual(work)
# Segmentos
seg_fmcost = _agg_segment(work, ["FM_COST_TYPE"], "SEGMENTO_FMCOST")
seg_fm_pais = _agg_segment(work, ["FM_COST_TYPE","PAIS"], "SEGMENTO_FMCOST_PAIS") if "PAIS" in work.columns else pd.DataFrame()
seg_fm_region = _agg_segment(work, ["FM_COST_TYPE","REGION"], "SEGMENTO_FMCOST_REGION") if "REGION" in work.columns else pd.DataFrame()
compare_out = pd.concat([cmp_port_month, cmp_port_annual, seg_fmcost, seg_fm_pais, seg_fm_region], ignore_index=True)
compare_out = compare_out.sort_values([ "scope", "clave", DATECOL ]).reset_index(drop=True)
compare_out.to_csv(OUT_COMPARE, sep=CSV_SEP, index=False)
log10(f"Guardado: {OUT_COMPARE} con {len(compare_out)} filas.")
[2025-09-24T12:38:20] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_metrics_reconc_vs_base.csv con 7021 filas.
Bloque 8 — Punto de control G y diagnósticos opcionales¶
# ---------------------------------------------------------
# Bloque 8 — Punto de control G y diagnósticos opcionales
# ---------------------------------------------------------
# Punto G: mejora WAPE anual del portfolio (absoluta en puntos porcentuales)
try:
row_ann = compare_out[compare_out["scope"]=="PORTFOLIO_ANUAL"].iloc[0]
wape_b = float(row_ann["WAPE_base"])
wape_r = float(row_ann["WAPE_reconc"])
delta_pp = 100.0 * (wape_r - wape_b)
log10(f"Punto G — WAPE anual portfolio (base={wape_b:.6f}, reconc={wape_r:.6f}, Δ={delta_pp:+.2f} pp)")
if abs(delta_pp) < 1.0:
log10("Punto G — Mejora marginal (< 1 pp). Recomendamos conservar versión no reconciliada como entrega principal y dejar esta como anexo.")
else:
log10("Punto G — Hay cambio apreciable (>= 1 pp). Podemos considerar entregar la reconciliada si no penaliza métricas clave.")
except Exception as e:
log10(f"AVISO Punto G — no fue posible calcular WAPE anual del portfolio: {e}")
# Histograma opcional de deltas en bottom (reconc - base) por mes
try:
import matplotlib.pyplot as plt
deltas = (preds_reconc["yhat_reconc"] - preds_reconc["yhat_base"]).astype(float)
plt.figure()
deltas.plot(kind="hist", bins=30)
plt.title("Distribución de deltas bottom (yhat_reconc - yhat_base)")
plt.xlabel("Delta")
plt.ylabel("Frecuencia")
fig_path = os.path.join(FIGS_DIR, "hist_deltas_bottom.png")
plt.tight_layout(); plt.savefig(fig_path); plt.close()
log10(f"Guardado histograma: {fig_path}")
except Exception as e:
log10(f"AVISO: no fue posible guardar histograma de deltas: {e}")
log10("Paso 10 finalizado.")
[2025-09-24T12:38:20] Punto G — WAPE anual portfolio (base=0.358801, reconc=0.333307, Δ=-2.55 pp) [2025-09-24T12:38:20] Punto G — Hay cambio apreciable (>= 1 pp). Podemos considerar entregar la reconciliada si no penaliza métricas clave. [2025-09-24T12:38:20] Guardado histograma: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_step10/hist_deltas_bottom.png [2025-09-24T12:38:20] Paso 10 finalizado.
Bloque 9 - Visualización resultados del Paso 10 — Efecto de la reconciliación (con real 2024)¶
# ============================================================
# Bloque 9 - Visualización resultados del Paso 10 — Efecto de la reconciliación (con real 2024)
# Requiere que en el Paso 10 hayamos generado:
# - RESULTADOS/preds_reconciliadas_2024.csv
# - REPORTING/compare_metrics_reconc_vs_base.csv
# - RESULTADOS/test_full_2024.csv
# - METRICAS/dim_buildings.csv (opcional: para PAIS/REGION)
# ============================================================
# Rutas base (mismas del proyecto)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step10")
os.makedirs(FIGS_DIR, exist_ok=True)
# Entradas
PATH_PREDS_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv")
PATH_COMPARE = os.path.join(RUTA_REPORTING, "compare_metrics_reconc_vs_base.csv")
PATH_TEST = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv")
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
VAL_TRUE = "cost_float_mod"
def _ensure_ms(df, datecol):
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
# Carga
preds_reconc = pd.read_csv(PATH_PREDS_RECONC, sep=CSV_SEP)
preds_reconc[DATECOL] = pd.to_datetime(preds_reconc[DATECOL])
compare_df = pd.read_csv(PATH_COMPARE, sep=CSV_SEP)
compare_df[DATECOL] = pd.to_datetime(compare_df[DATECOL], errors="coerce")
truth = pd.read_csv(PATH_TEST, sep=CSV_SEP)
truth = _ensure_ms(truth, DATECOL)[[*PAIR_COLS, DATECOL, VAL_TRUE]]
# Dim opcional
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP) if os.path.exists(PATH_DIM) else pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION"])
if "PAIS" not in dim.columns and "COUNTRY_DEF" in dim.columns:
dim = dim.rename(columns={"COUNTRY_DEF":"PAIS"})
if "REGION" not in dim.columns and "ID_REGION_GRUPO" in dim.columns:
dim = dim.rename(columns={"ID_REGION_GRUPO":"REGION"})
dim = dim[[c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim.columns]].drop_duplicates()
# --- 1) TABLA de deltas en bottom (reconc - base) ---
tmp = preds_reconc.copy()
tmp["delta"] = tmp["yhat_reconc"] - tmp["yhat_base"]
# Top ±20 ajustes en absoluto (para inspección)
top_up = tmp.sort_values("delta", ascending=False).head(20)
top_down = tmp.sort_values("delta", ascending=True).head(20)
out_table = pd.concat([
top_up.assign(tag="TOP_POS"),
top_down.assign(tag="TOP_NEG")
]).merge(dim, on="ID_BUILDING", how="left")
out_table_path = os.path.join(RUTA_REPORTING, "compare_vistas_visual.csv")
out_table.to_csv(out_table_path, sep=CSV_SEP, index=False)
# --- 2) AGREGADO PORTFOLIO: antes vs después vs verdad ---
portfolio = (tmp
.merge(truth, on=PAIR_COLS+[DATECOL], how="left")
.groupby(DATECOL, as_index=False)
.agg(y_base=("yhat_base","sum"),
y_reco=("yhat_reconc","sum"),
y_true=(VAL_TRUE,"sum")))
# --- 3) AGREGADO FM_COST_TYPE (mensual) ---
by_fm = (tmp
.merge(truth, on=PAIR_COLS+[DATECOL], how="left")
.groupby(["FM_COST_TYPE", DATECOL], as_index=False)
.agg(y_base=("yhat_base","sum"),
y_reco=("yhat_reconc","sum"),
y_true=(VAL_TRUE,"sum")))
# --- 4) PLOTS ---
# 4.1 Histograma de deltas bottom
plt.figure()
tmp["delta"].plot(kind="hist", bins=40)
plt.title("Deltas bottom (yhat_reconc - yhat_base)")
plt.xlabel("Delta"); plt.ylabel("Frecuencia")
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "hist_deltas_bottom.png")); plt.close()
# 4.2 Boxplot de deltas por FM_COST_TYPE
plt.figure(figsize=(10,5))
tmp_box = tmp.groupby(["FM_COST_TYPE"]).apply(lambda d: d["delta"].values)
# Convertimos para boxplot
data_box = [tmp[tmp["FM_COST_TYPE"]==k]["delta"].values for k in sorted(tmp["FM_COST_TYPE"].unique())]
plt.boxplot(data_box, labels=sorted(tmp["FM_COST_TYPE"].unique()), showfliers=False)
plt.title("Distribución de deltas por FM_COST_TYPE")
plt.xlabel("FM_COST_TYPE"); plt.ylabel("Delta (reconc - base)")
plt.xticks(rotation=45, ha="right")
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "box_deltas_por_fmcost.png")); plt.close()
# 4.3 Scatter y_base vs y_reco (portfolio mensual) con línea y=x
plt.figure()
plt.scatter(portfolio["y_base"], portfolio["y_reco"])
minv = float(np.nanmin([portfolio["y_base"].min(), portfolio["y_reco"].min()]))
maxv = float(np.nanmax([portfolio["y_base"].max(), portfolio["y_reco"].max()]))
plt.plot([minv, maxv], [minv, maxv])
plt.title("Portfolio mensual: y_base vs y_reco")
plt.xlabel("y_base (suma mes)"); plt.ylabel("y_reco (suma mes)")
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "scatter_portfolio_base_vs_reco.png")); plt.close()
# 4.4 Líneas en el tiempo (portfolio): base vs reco vs true
plt.figure(figsize=(10,5))
plt.plot(portfolio[DATECOL], portfolio["y_base"], label="base")
plt.plot(portfolio[DATECOL], portfolio["y_reco"], label="reconc")
if portfolio["y_true"].notna().any():
plt.plot(portfolio[DATECOL], portfolio["y_true"], label="true")
plt.title("Portfolio mensual — total (base vs reconc vs true)")
plt.xlabel("Mes"); plt.ylabel("Suma mensual")
plt.legend()
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "line_portfolio_base_reco_true.png")); plt.close()
# 4.5 Barras apiladas por FM_COST_TYPE (anual): base vs reco vs true
by_fm_year = by_fm.groupby("FM_COST_TYPE", as_index=False).agg(y_base=("y_base","sum"),
y_reco=("y_reco","sum"),
y_true=("y_true","sum"))
by_fm_year = by_fm_year.sort_values("y_true", ascending=False)
x = np.arange(len(by_fm_year))
w = 0.35
plt.figure(figsize=(12,5))
plt.bar(x - w/2, by_fm_year["y_base"], width=w, label="base")
plt.bar(x + w/2, by_fm_year["y_reco"], width=w, label="reconc")
plt.xticks(x, by_fm_year["FM_COST_TYPE"], rotation=45, ha="right")
plt.title("Total 2024 por FM_COST_TYPE — base vs reconc")
plt.ylabel("Suma anual")
plt.legend()
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "bar_fmcost_anual_base_vs_reco.png")); plt.close()
# 4.6 Efecto en error relativo mensual (WAPE) a nivel portfolio
def wape_micro(y_true, y_pred, eps=1e-8):
y_true = np.asarray(y_true, dtype=float)
y_pred = np.asarray(y_pred, dtype=float)
return float(np.sum(np.abs(y_pred - y_true)) / (np.sum(np.abs(y_true)) + eps))
wapes = []
for m, g in (tmp.merge(truth, on=PAIR_COLS+[DATECOL], how="left")
.groupby(DATECOL)):
y_true = g[VAL_TRUE].values
yb = g["yhat_base"].values
yr = g["yhat_reconc"].values
if np.isfinite(y_true).sum() == 0:
continue
wapes.append((m, wape_micro(y_true, yb), wape_micro(y_true, yr)))
wapes = pd.DataFrame(wapes, columns=[DATECOL,"WAPE_base","WAPE_reconc"]).sort_values(DATECOL)
plt.figure(figsize=(10,5))
plt.plot(wapes[DATECOL], wapes["WAPE_base"], label="WAPE base")
plt.plot(wapes[DATECOL], wapes["WAPE_reconc"], label="WAPE reconc")
plt.title("Portfolio — WAPE mensual (menor es mejor)")
plt.xlabel("Mes"); plt.ylabel("WAPE")
plt.legend()
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "line_portfolio_WAPE_mensual.png")); plt.close()
# 4.7 Top 20 ajustes absolutos (barras horizontales)
tops = (tmp.assign(abs_delta=tmp["delta"].abs())
.sort_values("abs_delta", ascending=False)
.head(20)
.merge(dim, on="ID_BUILDING", how="left"))
labs = tops.apply(lambda r: f"{r['ID_BUILDING']}|{r['FM_COST_TYPE']}|{str(r.get('PAIS',''))}", axis=1)
plt.figure(figsize=(10,8))
plt.barh(range(len(tops)), tops["delta"])
plt.yticks(range(len(tops)), labs)
plt.gca().invert_yaxis()
plt.title("Top 20 ajustes absolutos en bottom (reconc - base)")
plt.xlabel("Delta")
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "barh_top20_deltas_abs.png")); plt.close()
print("Listo. Archivos generados:")
print(" - Tabla de inspección:", out_table_path)
print(" - Figuras en:", FIGS_DIR)
Listo. Archivos generados: - Tabla de inspección: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_vistas_visual.csv - Figuras en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_step10
# Visualizamos los resultados
# Rutas base
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step10")
TABLE_VISUAL = os.path.join(RUTA_REPORTING, "compare_vistas_visual.csv")
# 1) Cargar tabla de inspección
if os.path.exists(TABLE_VISUAL):
df_visual = pd.read_csv(TABLE_VISUAL, sep=CSV_SEP)
print("Tabla de inspección (primeras filas):")
display(df_visual.head(10))
else:
print("No se encontró compare_vistas_visual.csv")
# 2) Mostrar imágenes de la carpeta de figuras
if os.path.exists(FIGS_DIR):
figs = [f for f in os.listdir(FIGS_DIR) if f.lower().endswith((".png",".jpg",".jpeg"))]
for f in sorted(figs):
img_path = os.path.join(FIGS_DIR, f)
img = mpimg.imread(img_path)
plt.figure(figsize=(8,5))
plt.imshow(img)
plt.axis("off")
plt.title(f)
plt.show()
else:
print("No se encontró la carpeta de figuras:", FIGS_DIR)
Tabla de inspección (primeras filas):
ID_BUILDING | FM_COST_TYPE | FECHA | yhat_reconc | yhat_base | delta | tag | PAIS | REGION | |
---|---|---|---|---|---|---|---|---|---|
0 | 1211 | Suministros | 2024-10-01 | 198613.834749 | 26955.697199 | 171658.137550 | TOP_POS | COLOMBIA | 105 |
1 | 1211 | Suministros | 2024-09-01 | 174914.140162 | 21433.232887 | 153480.907275 | TOP_POS | COLOMBIA | 105 |
2 | 1000270 | Servicios Extra | 2024-09-01 | 115162.783533 | 68.000000 | 115094.783533 | TOP_POS | ESPAÑA | 18 |
3 | 1000252 | Suministros | 2024-10-01 | 113990.137276 | 20072.233178 | 93917.904098 | TOP_POS | COLOMBIA | 105 |
4 | 1000270 | Servicios Extra | 2024-10-01 | 86419.833846 | 0.000000 | 86419.833846 | TOP_POS | ESPAÑA | 18 |
5 | 1000252 | Suministros | 2024-09-01 | 99839.584473 | 15866.838782 | 83972.745691 | TOP_POS | COLOMBIA | 105 |
6 | 1213 | Suministros | 2024-10-01 | 96493.879343 | 20511.594779 | 75982.284564 | TOP_POS | COLOMBIA | 105 |
7 | 1000483 | Suministros | 2024-10-01 | 109642.156988 | 37616.916077 | 72025.240911 | TOP_POS | REPÚBLICA DOMINICANA | 1000039 |
8 | 1213 | Suministros | 2024-09-01 | 84458.308900 | 16521.943412 | 67936.365488 | TOP_POS | COLOMBIA | 105 |
9 | 1000483 | Suministros | 2024-09-01 | 102800.948027 | 38402.606769 | 64398.341258 | TOP_POS | REPÚBLICA DOMINICANA | 1000039 |
dim_df.head(20)
ID_BUILDING | PAIS | REGION | |
---|---|---|---|
0 | 118 | ESPAÑA | 17 |
1 | 648 | ESPAÑA | 2 |
2 | 924 | ESPAÑA | 2 |
3 | 1001102 | PERÚ | 1000025 |
4 | 1000841 | ESPAÑA | 12 |
5 | 927 | ESPAÑA | 2 |
6 | 1000825 | ESPAÑA | 2 |
7 | 1000491 | ESPAÑA | 5 |
8 | 1000826 | ESPAÑA | 2 |
9 | 708 | ESPAÑA | 2 |
10 | 645 | ESPAÑA | 2 |
11 | 792 | ESPAÑA | 2 |
12 | 701 | ESPAÑA | 2 |
13 | 925 | ESPAÑA | 2 |
14 | 1000828 | ESPAÑA | 2 |
15 | 607 | ESPAÑA | 2 |
16 | 1000831 | ESPAÑA | 2 |
17 | 1128 | ESPAÑA | 2 |
18 | 1127 | ESPAÑA | 2 |
19 | 1000835 | ESPAÑA | 2 |
Paso 11 - Conciliación jerárquica ligera (MinT shrink) sobre previsiones 2024 sin datos reales o verdad terreno de 2024 (preparado para producción)¶
El espíritu es similar al Paso 10 (usando MinT “shrink”), pero robusto a la ausencia de datos reales con los que reconciliar. Vamos a dejar una referencia de manera opcional (planes/presupuestos) y, si no están, usaremos autoconsistencia.
Aunque estemos en desarrollo, preparar desde ya la versión sin verdad terreno nos evita fricciones al pasar a producción. En operación real normalmente no tendremos el test_full_2024
(las realizaciones del futuro), así que necesitamos una reconciliación jerárquica que:
no dependa de la verdad (real),
use ancoras opcionales si existen (por ejemplo, presupuestos/targets aprobados), y
sea autoconsistente cuando no haya ancora (por ejemplo, que las agregaciones de las predicciones cuadren con la jerarquía).
El Paso 10 (desarrollo/validación) lo usaremos mientras tengamos un período holdout con verdad. Nos sirve para medir el impacto real de reconciliar (WAPE, SMAPE, MASE) y definir políticas (por ejemplo, si la mejora es < 1 pp, no usar reconciliación).
El Paso 11 (producción/operación) por el contrario lo usaremos para la entrega operativa cuando no tengamos verdad. Reconciliamos “a ciegas”:
- Si tenemos referencias (por ejemplo, plan anual/mes a nivel
FM_COST_TYPE
xPaís
), reconciliamos hacia esa referencia (usando MinT con objetivo externo).
Si no hay referencia, reconciliamos para lograr consistencia interna (que las sumas por niveles coincidan), tomando como objetivo la propia agregación del bottom.
NOTA: En este caso, por simplicidad en producción, el bloque de construcción de W decidimos dejarla en la identidad, es decir, todas las predicciones a conciliar pesan igual y no dependen del valor de MASE12.
Bloque 0 — Parámetros y rutas¶
# ============================================================
# ESTRATEGIA 3 — PASO 11 (PRODUCCIÓN)
# Reconciliación jerárquica MinT (shrink) sin datos reales en test
# ============================================================
# En este paso:
# - Leemos predicciones base 2024 por serie (bottom)
# - Leemos anclas/targets por nivel agregado si existen (p.ej. planes)
# - Construimos jerarquía mínima:
# Bottom: (ID_BUILDING, FM_COST_TYPE)
# Nivel 1 opcional: (FM_COST_TYPE, PAIS) si PAIS existe
# Nivel 2 opcional: (FM_COST_TYPE, REGION) si REGION existe
# Top opcional: (FM_COST_TYPE)
# - Reconciliamos mes a mes mediante MinT (shrink simple) en forma WLS con restricciones:
# y_b_rec = y_b + W_b A' (A W_b A')^{-1} (c - A y_b)
# A es (n_agg x n_bottom); W_b diagonal (varianzas bottom)
# c es el vector de objetivos agregados:
# * si tenemos anclas (planes) para ese mes y nivel -> las usamos
# * si no, usamos c = A y_b (autoconsistencia)
# - Clippamos a >= 0 en bottom
# - Dado que no tenemos verdad, construimos comparativos estructurales
# (consistencia por nivel, magnitud de ajustes, distribución de deltas).
# - Dejamos salidas en RESULTADOS/ y REPORTING/ para auditoría.
# ============================================================
# -----------------------------
# Bloque 0 — Parámetros y rutas
# -----------------------------
# Fijamos semillas
np.random.seed(7)
# Rutas base (mismas convenciones del proyecto)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
os.makedirs(RUTA_RESULTADOS, exist_ok=True)
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_REPORTING, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
# Entradas obligatorias
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv") # del Paso 5
# Entradas opcionales
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # del Paso 9 (PAIS/REGION)
PATH_PASO8 = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv") # para proxy de W
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv") # para escalas/var proxy
PATH_ANCLA = os.path.join(RUTA_METRICAS, "anclas_reconciliacion_2024.csv") # OPCIONAL: objetivos por nivel
# Salidas
OUT_PREDS_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024_prod.csv")
OUT_COMPARE = os.path.join(RUTA_REPORTING, "compare_estructural_reconc_vs_base_prod.csv")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step11")
os.makedirs(FIGS_DIR, exist_ok=True)
# Claves/fechas
PAIR_COLS = ["ID_BUILDING", "FM_COST_TYPE"]
DATECOL = "FECHA"
VALCOL_PRED = "yhat_combo"
H = 12
MONTHS_2024 = pd.date_range("2024-01-01", periods=H, freq="MS")
# Logging simple
LOG_PATH = os.path.join(RUTA_LOGS, "estrategia3_step11.log")
def log11(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
Bloque 1 — Utilidades generales/IO¶
# -------------------------------------
# Bloque 1 — Utilidades generales/IO
# -------------------------------------
EPS = 1e-8
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
# Normalizamos a comienzo de mes (MS) para alinear malla
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _read_csv(path: str, required: bool=True) -> pd.DataFrame:
# Leemos CSV con limpieza de columnas; si required=False y no existe, devolvemos vacío
if os.path.exists(path):
df = pd.read_csv(path, sep=CSV_SEP)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta archivo requerido: {path}")
log11(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _norm_pair_keys(df: pd.DataFrame) -> pd.DataFrame:
# Normalizamos claves de par y tipamos lo que se pueda
if df is None or df.empty:
return df
out = df.copy()
out.columns = [str(c).strip() for c in out.columns]
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
if "FM_COST_TYPE" in out.columns:
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
def _safe_div(num, den, eps=EPS):
return num / (den + eps)
Bloque 2 — Carga de entradas y preparación (sin verdad terreno)¶
# -----------------------------------------------------------------
# Bloque 2 — Carga de entradas y preparación (sin verdad terreno)
# -----------------------------------------------------------------
log11("Iniciamos Paso 11 — Reconciliación MinT (producción, sin verdad).")
# Predicciones bottom (obligatorias)
preds_df = _ensure_ms(_read_csv(PATH_PREDS, required=True), DATECOL)
preds_df = _norm_pair_keys(preds_df)
if VALCOL_PRED not in preds_df.columns:
raise ValueError(f"Esperábamos columna {VALCOL_PRED} en {os.path.basename(PATH_PREDS)}.")
# Nos quedamos con las columnas mínimas más trazabilidad (si existen)
keep_pred_cols = [c for c in [*PAIR_COLS, DATECOL, VALCOL_PRED, "route", "fallback_flag"] if c in preds_df.columns]
preds_df = preds_df[keep_pred_cols].copy()
# Agregamos por si hubiera duplicados de (pair, mes)
preds_df = (preds_df
.groupby(PAIR_COLS + [DATECOL], as_index=False)
.agg({VALCOL_PRED: "sum",
**({"route":"last"} if "route" in preds_df.columns else {}),
**({"fallback_flag":"max"} if "fallback_flag" in preds_df.columns else {})}))
# Dimensiones opcionales
dim_df = _read_csv(PATH_DIM, required=False)
if not dim_df.empty:
# Normalizamos nombres esperados
dim_cols_map = {}
for c in dim_df.columns:
cu = c.strip().upper()
if cu == "PAIS": dim_cols_map[c] = "PAIS"
elif cu == "REGION": dim_cols_map[c] = "REGION"
elif cu == "ID_BUILDING": dim_cols_map[c] = "ID_BUILDING"
dim_df = dim_df.rename(columns=dim_cols_map)
dim_df = _norm_pair_keys(dim_df)
dim_df = dim_df[[c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim_df.columns]].drop_duplicates()
else:
dim_df = pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION"])
# Posibles anclas (opcional)
# Esperamos un archivo con columnas:
# - nivel: uno de {"FM_COST_TYPE","FM_COST_TYPE|PAIS","FM_COST_TYPE|REGION"}
# - clave: concatenación de las llaves del nivel (p.ej. "ENERGIA|ESPAÑA")
# - FECHA: primer día de mes (MS)
# - target: valor objetivo para ese nivel y mes
ancla_df = _read_csv(PATH_ANCLA, required=False)
if not ancla_df.empty:
# Normalizamos fechas y nombres
if DATECOL in ancla_df.columns:
ancla_df = _ensure_ms(ancla_df, DATECOL)
else:
raise KeyError("El CSV de anclas debe incluir columna FECHA.")
# Limpiamos
ancla_df.columns = [str(c).strip() for c in ancla_df.columns]
needed = {"nivel","clave",DATECOL,"target"}
if not needed.issubset(set(ancla_df.columns)):
raise KeyError(f"Anclas: faltan columnas {list(needed - set(ancla_df.columns))}")
# Filtramos a 2024 y reindexamos por seguridad
ancla_df = ancla_df[ancla_df[DATECOL].isin(MONTHS_2024)].copy()
log11(f"Cargadas entradas: preds={len(preds_df)} filas, dim={len(dim_df)} filas, anclas={'sí' if not ancla_df.empty else 'no'}")
log11(f"scipy.sparse disponible: {SCIPY_AVAILABLE}")
[2025-09-26T06:02:22] Iniciamos Paso 11 — Reconciliación MinT (producción, sin verdad). [2025-09-26T06:02:23] AVISO: no encontramos anclas_reconciliacion_2024.csv (continuamos sin este artefacto). [2025-09-26T06:02:23] Cargadas entradas: preds=29148 filas, dim=1650 filas, anclas=no [2025-09-26T06:02:23] scipy.sparse disponible: True
Bloque 3 — Preparación del panel y metadatos jerárquicos¶
# ------------------------------------------------------------
# Bloque 3 — Preparación del panel y metadatos jerárquicos
# ------------------------------------------------------------
# Unimos PAIS/REGION al bottom
bottom_df = preds_df.merge(dim_df, on="ID_BUILDING", how="left")
n_bottom = bottom_df[PAIR_COLS].drop_duplicates().shape[0]
# Diagnóstico metadatos
pct_no_pais = 1.0 - float(bottom_df["PAIS"].notna().mean()) if "PAIS" in bottom_df.columns else 1.0
pct_no_region = 1.0 - float(bottom_df["REGION"].notna().mean()) if "REGION" in bottom_df.columns else 1.0
log11(f"Metadatos: n_bottom={n_bottom} | % sin PAIS={100*pct_no_pais:.1f}% | % sin REGION={100*pct_no_region:.1f}%")
# Claves por nivel presentes
def _unique_keys(df, cols):
cols = [c for c in cols if c in df.columns]
if len(cols)==0:
return pd.DataFrame(columns=cols)
return df[cols].dropna().drop_duplicates()
keys_top = _unique_keys(bottom_df, ["FM_COST_TYPE"])
keys_fm_pais = _unique_keys(bottom_df, ["FM_COST_TYPE","PAIS"])
keys_fm_region = _unique_keys(bottom_df, ["FM_COST_TYPE","REGION"])
keys_bottom = bottom_df[PAIR_COLS].drop_duplicates().sort_values(PAIR_COLS).reset_index(drop=True)
if keys_top.empty:
log11("AVISO: capa TOP (FM_COST_TYPE) vacía; seguimos sin top.")
if "PAIS" not in bottom_df.columns or keys_fm_pais.empty:
log11("AVISO: capa (FM_COST_TYPE, PAIS) no disponible; seguimos sin esa capa.")
if "REGION" not in bottom_df.columns or keys_fm_region.empty:
log11("AVISO: capa (FM_COST_TYPE, REGION) no disponible; seguimos sin esa capa.")
# Índices auxiliares
def _child_idx_for_top(fm):
mask = (keys_bottom["FM_COST_TYPE"]==fm)
return keys_bottom.index[mask].tolist()
def _child_idx_for_fm_pais(fm, pais):
if "PAIS" not in bottom_df.columns:
return []
sub = bottom_df.merge(keys_bottom[[*PAIR_COLS]], on=PAIR_COLS, how="inner")
sub = sub[(sub["FM_COST_TYPE"]==fm) & (sub["PAIS"]==pais)]
idx = keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
return idx
def _child_idx_for_fm_region(fm, region):
if "REGION" not in bottom_df.columns:
return []
sub = bottom_df.merge(keys_bottom[[*PAIR_COLS]], on=PAIR_COLS, how="inner")
sub = sub[(sub["FM_COST_TYPE"]==fm) & (sub["REGION"]==region)]
idx = keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
return idx
n_top = len(keys_top) if not keys_top.empty else 0
n_fm_pais = len(keys_fm_pais) if not keys_fm_pais.empty else 0
n_fm_region = len(keys_fm_region) if not keys_fm_region.empty else 0
log11(f"Árbol: top={n_top} | fm×pais={n_fm_pais} | fm×region={n_fm_region} | bottom={n_bottom}")
[2025-09-26T06:02:27] Metadatos: n_bottom=2429 | % sin PAIS=0.0% | % sin REGION=0.0% [2025-09-26T06:02:27] Árbol: top=8 | fm×pais=49 | fm×region=573 | bottom=2429
Bloque 4 — Construcción de A (solo niveles agregados)¶
# -----------------------------------------------------
# Bloque 4 — Construcción de A (solo niveles agregados)
# -----------------------------------------------------
def _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region):
agg_rows = []
def _add_row(child_idx_list):
if not child_idx_list: # evita fila cero
return
row = np.zeros(n_bottom, dtype=float)
row[np.asarray(child_idx_list, dtype=int)] = 1.0
agg_rows.append(row)
if not keys_top.empty:
for (fm,) in keys_top.itertuples(index=False, name=None):
_add_row(_child_idx_for_top(fm))
if not keys_fm_pais.empty:
for (fm, pais) in keys_fm_pais.itertuples(index=False, name=None):
_add_row(_child_idx_for_fm_pais(fm, pais))
if not keys_fm_region.empty:
for (fm, region) in keys_fm_region.itertuples(index=False, name=None):
_add_row(_child_idx_for_fm_region(fm, region))
return np.vstack(agg_rows) if agg_rows else np.zeros((0, n_bottom), dtype=float)
A = _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_region)
A_sparse = (sp.csr_matrix(A) if (SCIPY_AVAILABLE and A.size>0) else None)
log11(f"Matriz A (agregados): shape={A.shape}")
[2025-09-26T06:03:37] Matriz A (agregados): shape=(630, 2429)
Bloque 5 — Estimación de W (diagonal) bottom sin verdad¶
# ---------------------------------------------------------
# Bloque 5 — Estimación de W (diagonal) bottom sin verdad
# ---------------------------------------------------------
# Sin verdad, nos apoyamos en proxies:
# - Si tenemos Paso 8 con MASE12 por pareja, usamos MASE12^2 como proxy de varianza
# - Si tenemos train, podemos usar dispersión histórica como proxy (por ejemplo, var de primeras diferencias)
# - Fallback: identidad
# Proxy desde Paso 8
proxy_mase = {}
paso8_df = _read_csv(PATH_PASO8, required=False)
if not paso8_df.empty:
paso8_df.columns = [str(c).strip() for c in paso8_df.columns]
if set(PAIR_COLS).issubset(paso8_df.columns) and "MASE12" in paso8_df.columns:
if "vista" in paso8_df.columns:
paso8_df = paso8_df[paso8_df["vista"].astype(str).str.lower().eq("all_months")]
paso8_df = paso8_df[[*PAIR_COLS, "MASE12"]].drop_duplicates(PAIR_COLS)
for r in paso8_df.itertuples(index=False):
proxy_mase[(getattr(r, PAIR_COLS[0]), getattr(r, PAIR_COLS[1]))] = float(pd.to_numeric(getattr(r, "MASE12"), errors="coerce"))
# Proxy desde train (dispersión histórica de primeras diferencias)
proxy_hist = {}
train_df = _read_csv(PATH_TRAIN, required=False)
if not train_df.empty and all(c in train_df.columns for c in [*PAIR_COLS, DATECOL, "cost_float_mod"]):
tr = _ensure_ms(_norm_pair_keys(train_df), DATECOL)
for (bid, ctype), g in tr.groupby(PAIR_COLS, as_index=False):
y = pd.to_numeric(g["cost_float_mod"], errors="coerce").astype(float).values
if len(y) >= 13:
diffs = np.diff(y)
v = float(np.nanvar(diffs, ddof=1)) if np.sum(~np.isnan(diffs))>=2 else float(np.nanvar(diffs))
if not np.isfinite(v) or v<=0: v = np.nan
else:
v = np.nan
proxy_hist[(bid, ctype)] = v
# Combinamos proxies para W_diag
keys_bottom_tuples = [tuple(x) for x in keys_bottom[PAIR_COLS].itertuples(index=False, name=None)]
W_diag = np.zeros(n_bottom, dtype=float)
n_from_mase, n_from_hist = 0, 0
for i, k in enumerate(keys_bottom_tuples):
v = np.nan
# 1) MASE12^2
m = proxy_mase.get(k, np.nan)
if np.isfinite(m):
v = max(m**2, 1e-8)
n_from_mase += 1
else:
# 2) Var de diffs históricas
h = proxy_hist.get(k, np.nan)
if np.isfinite(h) and h>0:
v = max(h, 1e-8)
n_from_hist += 1
else:
# 3) Fallback identidad (peso uniforme)
v = 1.0
W_diag[i] = v
W_diag = np.maximum(W_diag, 1e-8)
W_inv_diag = 1.0 / np.maximum(W_diag, 1e-8)
log11(f"W diagonal: bottom={n_bottom} | de MASE12={n_from_mase} | de hist_diffs={n_from_hist} | resto=identity={n_bottom - n_from_mase - n_from_hist}")
[2025-09-26T06:03:40] W diagonal: bottom=2429 | de MASE12=2171 | de hist_diffs=153 | resto=identity=105
Bloque 6 — c (objetivos por nivel): ancla externa o autoconsistencia¶
# ----------------------------------------------------------------
# Bloque 6 — c (objetivos por nivel): referencia o ancla externa o autoconsistencia
# ----------------------------------------------------------------
# Construimos helpers para obtener y_b del mes y el vector c del mes
def _month_slice_pred(month_ts):
sub = preds_df[preds_df[DATECOL].eq(month_ts)]
merged = keys_bottom.merge(sub, on=PAIR_COLS, how="left")
yb = merged[VALCOL_PRED].fillna(0.0).values.astype(float)
return yb
def _c_from_anchor_or_base(month_ts, y_b):
# Si no hay A (sin agregados), devolvemos vector vacío
if A.shape[0] == 0:
return np.zeros(0, dtype=float)
# Si hay anclas y coinciden con nuestra capa, las usamos
if not ancla_df.empty:
# Construimos A y orden de filas: primero TOP, luego FM×PAIS, luego FM×REGION (mismo orden aplicado en A)
targets = []
# TOP: "FM_COST_TYPE"
if not keys_top.empty:
for (fm,) in keys_top.itertuples(index=False, name=None):
clave = f"{fm}"
row = ancla_df[(ancla_df["nivel"]=="FM_COST_TYPE") &
(ancla_df["clave"]==clave) &
(ancla_df[DATECOL].eq(month_ts))]
if len(row)==1:
targets.append(float(row["target"].iloc[0]))
else:
targets.append(np.nan) # si no hay ancora, ponemos NaN para rellenar luego
# FM×PAIS
if not keys_fm_pais.empty:
for (fm, pais) in keys_fm_pais.itertuples(index=False, name=None):
clave = f"{fm}|{pais}"
row = ancla_df[(ancla_df["nivel"]=="FM_COST_TYPE|PAIS") &
(ancla_df["clave"]==clave) &
(ancla_df[DATECOL].eq(month_ts))]
targets.append(float(row["target"].iloc[0]) if len(row)==1 else np.nan)
# FM×REGION
if not keys_fm_region.empty:
for (fm, region) in keys_fm_region.itertuples(index=False, name=None):
clave = f"{fm}|{region}"
row = ancla_df[(ancla_df["nivel"]=="FM_COST_TYPE|REGION") &
(ancla_df["clave"]==clave) &
(ancla_df[DATECOL].eq(month_ts))]
targets.append(float(row["target"].iloc[0]) if len(row)==1 else np.nan)
c = np.asarray(targets, dtype=float)
# Donde no tengamos ancora, nos autoconsistimos con A y_b
if np.any(np.isnan(c)):
c_base = A @ y_b
c = np.where(np.isnan(c), c_base, c)
return c
# Sin anclas, usamos autoconsistencia
return A @ y_b
Bloque 7 — Reconciliación mensual (WLS con A y c sin verdad)¶
# ----------------------------------------------------------------
# Bloque 7 — Reconciliación mensual (WLS con A y c sin verdad)
# ----------------------------------------------------------------
def _mint_reconcile_for_month_bottomW(y_b, month_ts):
# Si no hay agregados, devolvemos bottom clippeado
if (n_bottom == 0) or (A.shape[0] == 0):
y_b_rec = np.maximum(y_b, 0.0)
return y_b_rec
# Objetivo c desde ancla o base
c = _c_from_anchor_or_base(month_ts, y_b)
# A W A' con W diagonal
if SCIPY_AVAILABLE and A_sparse is not None and A.shape[0] > 0:
from scipy.sparse import diags
Wb = diags(W_diag, offsets=0, format="csr")
AW = (A_sparse @ Wb) # (n_agg x m)
AWAT = (AW @ A_sparse.T).toarray()
else:
AW = A * W_diag # (n_agg x m)
AWAT = AW @ A.T # (n_agg x n_agg)
# Inversa estable
try:
AWAT_inv = np.linalg.pinv(AWAT, rcond=1e-10)
except Exception:
AWAT_inv = np.linalg.pinv(AWAT + 1e-8*np.eye(AWAT.shape[0]))
# Residual en agregados
r = c - (A @ y_b)
# Delta = W A' (A W A')^{-1} r
v = AWAT_inv @ r
if SCIPY_AVAILABLE and A_sparse is not None and A.shape[0] > 0:
tmp = (A_sparse.T @ v)
tmp = np.asarray(tmp).ravel()
delta = W_diag * tmp
else:
delta = ((A.T * W_diag[:, None]) @ v)
y_b_rec = y_b + delta
# No negatividad
y_b_rec = np.maximum(y_b_rec, 0.0)
return y_b_rec
# Ejecutamos reconciliación para los 12 meses
reconc_rows = []
for mth in MONTHS_2024:
yb = _month_slice_pred(mth)
yb_rec = _mint_reconcile_for_month_bottomW(yb, mth)
df_month = keys_bottom.copy()
df_month[DATECOL] = mth
df_month["yhat_base"] = yb
df_month["yhat_reconc"] = yb_rec
reconc_rows.append(df_month)
preds_reconc = pd.concat(reconc_rows, ignore_index=True)
preds_reconc = preds_reconc[[*PAIR_COLS, DATECOL, "yhat_reconc", "yhat_base"]].sort_values(PAIR_COLS + [DATECOL]).reset_index(drop=True)
preds_reconc.to_csv(OUT_PREDS_RECONC, sep=CSV_SEP, index=False)
log11(f"Guardado: {OUT_PREDS_RECONC} con {len(preds_reconc)} filas.")
[2025-09-26T06:03:55] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_2024_prod.csv con 29148 filas.
Bloque 8 — Comparaciones estructurales (sin verdad)¶
# ----------------------------------------------------------------
# Bloque 8 — Comparaciones estructurales (sin verdad)
# ----------------------------------------------------------------
# Como no tenemos verdad, generamos diagnósticos que nos permitan:
# - Ver la magnitud de ajustes por mes y por nivel
# - Verificar consistencia de sumas en cada capa
# - Medir cuánto nos alejamos de anclas cuando existen
def _agg_sum(df, valcol, group_cols):
return (df.groupby(group_cols, as_index=False)
.agg(total=(valcol, "sum")))
# Construimos panel base/reconc
tmp = preds_reconc.copy()
tmp["delta"] = tmp["yhat_reconc"] - tmp["yhat_base"]
# Portfolio mensual base vs reconc
port_base = _agg_sum(tmp, "yhat_base", [DATECOL])
port_reco = _agg_sum(tmp, "yhat_reconc", [DATECOL])
port = port_base.merge(port_reco, on=[DATECOL], suffixes=("_base","_reco"))
port["delta_portfolio"] = port["total_reco"] - port["total_base"]
# Agregado por FM_COST_TYPE
fm_base = _agg_sum(tmp, "yhat_base", ["FM_COST_TYPE", DATECOL])
fm_reco = _agg_sum(tmp, "yhat_reconc", ["FM_COST_TYPE", DATECOL])
fm = fm_base.merge(fm_reco, on=["FM_COST_TYPE", DATECOL], suffixes=("_base","_reco"))
fm["delta_fm"] = fm["total_reco"] - fm["total_base"]
# Agregado por FM_COST_TYPE×PAIS (si existe)
if "PAIS" in bottom_df.columns and bottom_df["PAIS"].notna().any():
df2 = tmp.merge(dim_df, on="ID_BUILDING", how="left")
fmp_base = _agg_sum(df2, "yhat_base", ["FM_COST_TYPE","PAIS", DATECOL])
fmp_reco = _agg_sum(df2, "yhat_reconc", ["FM_COST_TYPE","PAIS", DATECOL])
fmp = fmp_base.merge(fmp_reco, on=["FM_COST_TYPE","PAIS",DATECOL], suffixes=("_base","_reco"))
fmp["delta_fm_pais"] = fmp["total_reco"] - fmp["total_base"]
else:
fmp = pd.DataFrame(columns=["FM_COST_TYPE","PAIS",DATECOL,"total_base","total_reco","delta_fm_pais"])
# Agregado por FM_COST_TYPE×REGION (si existe)
if "REGION" in bottom_df.columns and bottom_df["REGION"].notna().any():
df3 = tmp.merge(dim_df, on="ID_BUILDING", how="left")
fmr_base = _agg_sum(df3, "yhat_base", ["FM_COST_TYPE","REGION", DATECOL])
fmr_reco = _agg_sum(df3, "yhat_reconc", ["FM_COST_TYPE","REGION", DATECOL])
fmr = fmr_base.merge(fmr_reco, on=["FM_COST_TYPE","REGION",DATECOL], suffixes=("_base","_reco"))
fmr["delta_fm_region"] = fmr["total_reco"] - fmr["total_base"]
else:
fmr = pd.DataFrame(columns=["FM_COST_TYPE","REGION",DATECOL,"total_base","total_reco","delta_fm_region"])
# Si hay anclas, medimos gap vs target
gaps_rows = []
if not ancla_df.empty and A.shape[0] > 0:
# TOP
if not keys_top.empty:
agg_top = fm.groupby(["FM_COST_TYPE", DATECOL], as_index=False).agg(y_reco=("total_reco","sum"))
agg_top["nivel"] = "FM_COST_TYPE"
agg_top["clave"] = agg_top["FM_COST_TYPE"].astype(str)
gaps_rows.append(agg_top[["nivel","clave",DATECOL,"y_reco"]].rename(columns={"y_reco":"valor"}))
# FM×PAIS
if not fmp.empty:
agg_fmp = fmp.rename(columns={"total_reco":"y_reco"})
agg_fmp["nivel"] = "FM_COST_TYPE|PAIS"
agg_fmp["clave"] = agg_fmp["FM_COST_TYPE"].astype(str) + "|" + agg_fmp["PAIS"].astype(str) # <- usar agg_fmp
gaps_rows.append(agg_fmp[["nivel","clave",DATECOL,"y_reco"]].rename(columns={"y_reco":"valor"}))
# FM×REGION
if not fmr.empty:
agg_fmr = fmr.rename(columns={"total_reco":"y_reco"})
agg_fmr["nivel"] = "FM_COST_TYPE|REGION"
agg_fmr["clave"] = agg_fmr["FM_COST_TYPE"].astype(str) + "|" + agg_fmr["REGION"].astype(str) # <- usar agg_fmr
gaps_rows.append(agg_fmr[["nivel","clave",DATECOL,"y_reco"]].rename(columns={"y_reco":"valor"}))
vals = pd.concat(gaps_rows, ignore_index=True) if len(gaps_rows)>0 else pd.DataFrame(columns=["nivel","clave",DATECOL,"valor"])
gaps = ancla_df.merge(vals, on=["nivel","clave",DATECOL], how="left")
gaps["gap_reco_vs_target"] = gaps["valor"] - gaps["target"]
else:
gaps = pd.DataFrame(columns=["nivel","clave",DATECOL,"target","valor","gap_reco_vs_target"])
# Guardamos comparativos estructurales en un único CSV (apilamos con una columna 'scope')
comparativos = []
port2 = port.copy(); port2["scope"] = "PORTFOLIO_MENSUAL"
comparativos.append(port2.rename(columns={"total_base":"base","total_reco":"reconc","delta_portfolio":"delta"}))
fm2 = fm.copy(); fm2["scope"] = "FM_COST_TYPE_MENSUAL"
fm2 = fm2.rename(columns={"total_base":"base","total_reco":"reconc","delta_fm":"delta"})
comparativos.append(fm2[["scope","FM_COST_TYPE",DATECOL,"base","reconc","delta"]])
if not fmp.empty:
fmp2 = fmp.copy(); fmp2["scope"] = "FM_COST_TYPE|PAIS_MENSUAL"
fmp2 = fmp2.rename(columns={"total_base":"base","total_reco":"reconc","delta_fm_pais":"delta"})
comparativos.append(fmp2[["scope","FM_COST_TYPE","PAIS",DATECOL,"base","reconc","delta"]])
if not fmr.empty:
fmr2 = fmr.copy(); fmr2["scope"] = "FM_COST_TYPE|REGION_MENSUAL"
fmr2 = fmr2.rename(columns={"total_base":"base","total_reco":"reconc","delta_fm_region":"delta"})
comparativos.append(fmr2[["scope","FM_COST_TYPE","REGION",DATECOL,"base","reconc","delta"]])
if not gaps.empty:
gaps2 = gaps.copy(); gaps2["scope"] = "GAP_RECO_VS_TARGET"
comparativos.append(gaps2[["scope","nivel","clave",DATECOL,"target","valor","gap_reco_vs_target"]])
compare_out = pd.concat(comparativos, ignore_index=True) if len(comparativos)>0 else pd.DataFrame()
compare_out.to_csv(OUT_COMPARE, sep=CSV_SEP, index=False)
log11(f"Guardado: {OUT_COMPARE} con {len(compare_out)} filas.")
[2025-09-26T06:03:56] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_estructural_reconc_vs_base_prod.csv con 7572 filas.
Bloque 9 - Diagnósticos gráficos mínimos (sin verdad)¶
# Bloque 9 - Diagnósticos gráficos mínimos (sin verdad)
try:
# Histograma de deltas bottom
plt.figure()
tmp["delta"].plot(kind="hist", bins=40)
plt.title("Deltas bottom (yhat_reconc - yhat_base)")
plt.xlabel("Delta"); plt.ylabel("Frecuencia")
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "hist_deltas_bottom_step11.png")); plt.close()
# Serie temporal portfolio base vs reconc
plt.figure(figsize=(10,5))
plt.plot(port[DATECOL], port["total_base"], label="base")
plt.plot(port[DATECOL], port["total_reco"], label="reconc")
plt.title("Portfolio mensual — base vs reconc (sin verdad)")
plt.xlabel("Mes"); plt.ylabel("Suma mensual")
plt.legend()
plt.tight_layout(); plt.savefig(os.path.join(FIGS_DIR, "line_portfolio_base_reco_step11.png")); plt.close()
log11(f"Guardadas figuras en {FIGS_DIR}")
except Exception as e:
log11(f"AVISO: no fue posible generar figuras: {e}")
log11("Paso 11 finalizado (producción).")
[2025-09-26T06:03:58] Guardadas figuras en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_step11 [2025-09-26T06:03:58] Paso 11 finalizado (producción).
Dado que el Paso 11 no usa verdad, no podremos comparar métricas de error entre ambos pasos; lo que sí podemos comparar es la forma y magnitud de los ajustes que introduce cada reconciliación. Para eso, nos basta con contrastar las predicciones reconciliadas bottom y los agregados.
A continuación, vamos a generar un informe puente que compare Paso 10 vs Paso 11 usando las dos salidas reconciliadas; lo dejamos en REPORTING/compare_step10_vs_step11.csv y no requiere verdad.
# ============================================================
# Puente de comparación — Paso 10 vs Paso 11
# Comparamos las reconciliaciones entre sí (sin verdad)
# ============================================================
# -----------------------------
# Bloque A — Parámetros y rutas
# -----------------------------
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
PATH_STEP10 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv")
PATH_STEP11 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024_prod.csv")
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
OUT_BRIDGE = os.path.join(RUTA_REPORTING, "compare_step10_vs_step11.csv")
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
# --------------------------------
# Bloque B — Carga y normalización
# --------------------------------
def _ensure_ms(df, datecol):
# Normalizamos a MS para asegurar alineación
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _norm_pair(df):
# Normalizamos claves
out = df.copy()
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
step10 = pd.read_csv(PATH_STEP10, sep=CSV_SEP)
step11 = pd.read_csv(PATH_STEP11, sep=CSV_SEP)
step10[DATECOL] = pd.to_datetime(step10[DATECOL])
step11[DATECOL] = pd.to_datetime(step11[DATECOL])
step10 = _norm_pair(step10); step11 = _norm_pair(step11)
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP) if os.path.exists(PATH_DIM) else pd.DataFrame()
if not dim.empty:
# Nos quedamos con PAIS y REGION si existen
keep = [c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim.columns]
dim = dim[keep].drop_duplicates()
# ------------------------------------------------------------
# Bloque C — Ensamble bottom y deltas entre reconciliaciones
# ------------------------------------------------------------
# Unimos por par y fecha; renombramos para distinguir
st10 = step10.rename(columns={"yhat_reconc":"yhat_reconc_step10"})
st11 = step11.rename(columns={"yhat_reconc":"yhat_reconc_step11"})
cols_merge = [*PAIR_COLS, DATECOL]
merged = st10[cols_merge + ["yhat_reconc_step10"]].merge(
st11[cols_merge + ["yhat_reconc_step11"]],
on=cols_merge, how="inner"
)
# Calculamos delta entre métodos
merged["delta_step11_vs_step10"] = merged["yhat_reconc_step11"] - merged["yhat_reconc_step10"]
merged["abs_delta"] = merged["delta_step11_vs_step10"].abs()
# Adjuntamos dimensiones si están disponibles
if not dim.empty:
merged = merged.merge(dim, on="ID_BUILDING", how="left")
# ------------------------------------------------------------
# Bloque D — Agregados por nivel para entender diferencias
# ------------------------------------------------------------
def _sum_by(gcols, label):
# Agregamos por grupo y fecha para comparar totales por método
g = merged.groupby(gcols + [DATECOL], as_index=False).agg(
y_step10=("yhat_reconc_step10","sum"),
y_step11=("yhat_reconc_step11","sum")
)
g["delta"] = g["y_step11"] - g["y_step10"]
g["scope"] = label
return g
# Portfolio
port = _sum_by([], "PORTFOLIO_MENSUAL")
# FM_COST_TYPE
by_fm = _sum_by(["FM_COST_TYPE"], "FM_COST_TYPE_MENSUAL")
# FM_COST_TYPE×PAIS si existe
if "PAIS" in merged.columns and merged["PAIS"].notna().any():
by_fm_pais = _sum_by(["FM_COST_TYPE","PAIS"], "FM_COST_TYPE|PAIS_MENSUAL")
else:
by_fm_pais = pd.DataFrame(columns=["scope","FM_COST_TYPE","PAIS",DATECOL,"y_step10","y_step11","delta"])
# FM_COST_TYPE×REGION si existe
if "REGION" in merged.columns and merged["REGION"].notna().any():
by_fm_region = _sum_by(["FM_COST_TYPE","REGION"], "FM_COST_TYPE|REGION_MENSUAL")
else:
by_fm_region = pd.DataFrame(columns=["scope","FM_COST_TYPE","REGION",DATECOL,"y_step10","y_step11","delta"])
# Apilamos salidas
bridge_top = port.copy()
bridge_fm = by_fm.copy()
frames = [bridge_top, bridge_fm]
if not by_fm_pais.empty: frames.append(by_fm_pais)
if not by_fm_region.empty: frames.append(by_fm_region)
bridge = pd.concat(frames, ignore_index=True)
# Guardamos resultado maestro y también anexamos bottom con deltas
# Para minimizar fricción, guardamos dos bloques en un mismo CSV
bridge.to_csv(OUT_BRIDGE, sep=CSV_SEP, index=False)
with open(OUT_BRIDGE, "a", encoding="utf-8") as f:
f.write("\n") # separador lógico
merged.to_csv(f, sep=CSV_SEP, index=False, header=True)
print(f"Guardado: {OUT_BRIDGE}")
Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_step10_vs_step11.csv
# ============================================================
# Comparación visual — Paso 10 vs Paso 11 (sin verdad)
# En este notebook comparamos las predicciones reconciliadas
# generadas por el Paso 10 (con verdad) y el Paso 11 (sin verdad)
# ============================================================
# -----------------------------
# Bloque A — Parámetros y rutas
# -----------------------------
# Definimos rutas base del proyecto
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
# Carpetas estándar
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step10_vs_step11")
os.makedirs(FIGS_DIR, exist_ok=True)
# Entradas necesarias
PATH_STEP10 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv") # Paso 10
PATH_STEP11 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024_prod.csv") # Paso 11
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # Opcional
# Constantes de clave
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
# --------------------------------
# Bloque B — Funciones utilitarias
# --------------------------------
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
# Normalizamos las fechas a comienzo de mes para alinear a malla MS
out = df.copy()
out[datecol] = pd.to_datetime(out[datecol]).dt.to_period("M").dt.to_timestamp()
return out
def _norm_pair(df: pd.DataFrame) -> pd.DataFrame:
# Normalizamos claves y tipamos
out = df.copy()
if "ID_BUILDING" in out.columns:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
if "FM_COST_TYPE" in out.columns:
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
def _load_csv(path: str, sep: str = CSV_SEP, required: bool = True) -> pd.DataFrame:
# Cargamos un CSV con validación opcional
if os.path.exists(path):
df = pd.read_csv(path, sep=sep)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta el archivo requerido: {path}")
print(f"[AVISO] No encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _sum_by(df: pd.DataFrame, gcols: list[str], datecol: str, c10: str, c11: str, label: str) -> pd.DataFrame:
# Agregamos por grupo y fecha para comparar totales por método
g = df.groupby(gcols + [datecol], as_index=False).agg(
y_step10=(c10,"sum"),
y_step11=(c11,"sum")
)
g["delta"] = g["y_step11"] - g["y_step10"]
g["scope"] = label
return g
def _annual_total(df: pd.DataFrame, key_cols: list[str], c10: str, c11: str, label: str) -> pd.DataFrame:
# Agregamos anual total por clave para comparar magnitudes
g = df.groupby(key_cols, as_index=False).agg(
y_step10=(c10,"sum"),
y_step11=(c11,"sum")
)
g["delta"] = g["y_step11"] - g["y_step10"]
g["scope"] = label
return g
# ---------------------------------------
# Bloque C — Carga, joins y transformados
# ---------------------------------------
# Cargamos reconciliaciones
step10 = _load_csv(PATH_STEP10)
step11 = _load_csv(PATH_STEP11)
# Normalizamos columnas de fecha y clave
for df in (step10, step11):
if DATECOL in df.columns:
df[DATECOL] = pd.to_datetime(df[DATECOL], errors="coerce")
df[:] = df # noop para dejar claro que mantenemos esquema
step10 = _norm_pair(step10)
step11 = _norm_pair(step11)
# Renombramos columnas para distinguir métodos
if "yhat_reconc" not in step10.columns or "yhat_reconc" not in step11.columns:
raise KeyError("Esperábamos columna 'yhat_reconc' en ambas entradas de reconciliación.")
st10 = step10.rename(columns={"yhat_reconc":"yhat_reconc_step10"})
st11 = step11.rename(columns={"yhat_reconc":"yhat_reconc_step11"})
# Merge de bottom por par y fecha
cols_merge = [*PAIR_COLS, DATECOL]
merged = (st10[cols_merge + ["yhat_reconc_step10"]]
.merge(st11[cols_merge + ["yhat_reconc_step11"]],
on=cols_merge, how="inner"))
# Calculamos delta entre métodos a nivel bottom
merged["delta_step11_vs_step10"] = merged["yhat_reconc_step11"] - merged["yhat_reconc_step10"]
merged["abs_delta"] = merged["delta_step11_vs_step10"].abs()
# Dimensiones opcionales (PAIS/REGION)
dim = _load_csv(PATH_DIM, required=False)
if not dim.empty:
keep = [c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim.columns]
if keep:
dim = dim[keep].drop_duplicates()
merged = merged.merge(dim, on="ID_BUILDING", how="left")
# Agregados por nivel y fecha
port = _sum_by(merged, [], DATECOL, "yhat_reconc_step10", "yhat_reconc_step11", "PORTFOLIO_MENSUAL")
by_fm = _sum_by(merged, ["FM_COST_TYPE"], DATECOL, "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE_MENSUAL")
if "PAIS" in merged.columns and merged["PAIS"].notna().any():
by_fm_pais = _sum_by(merged, ["FM_COST_TYPE","PAIS"], DATECOL, "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE|PAIS_MENSUAL")
else:
by_fm_pais = pd.DataFrame(columns=["scope","FM_COST_TYPE","PAIS",DATECOL,"y_step10","y_step11","delta"])
if "REGION" in merged.columns and merged["REGION"].notna().any():
by_fm_region = _sum_by(merged, ["FM_COST_TYPE","REGION"], DATECOL, "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE|REGION_MENSUAL")
else:
by_fm_region = pd.DataFrame(columns=["scope","FM_COST_TYPE","REGION",DATECOL,"y_step10","y_step11","delta"])
# Totales anuales por FM_COST_TYPE y por capa territorial si procede
annual_fm = _annual_total(merged, ["FM_COST_TYPE"], "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE_ANUAL")
annual_fm_pais = _annual_total(merged, ["FM_COST_TYPE","PAIS"], "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE|PAIS_ANUAL") if "PAIS" in merged.columns else pd.DataFrame()
annual_fm_region= _annual_total(merged, ["FM_COST_TYPE","REGION"], "yhat_reconc_step10", "yhat_reconc_step11", "FM_COST_TYPE|REGION_ANUAL") if "REGION" in merged.columns else pd.DataFrame()
# ------------------------------------------------
# Bloque D — Visualizaciones (cada plot independiente)
# ------------------------------------------------
# 1) Portfolio mensual: líneas base vs prod y delta
plt.figure(figsize=(10,5))
plt.plot(port[DATECOL], port["y_step10"], label="Paso 10 — reconc")
plt.plot(port[DATECOL], port["y_step11"], label="Paso 11 — reconc")
plt.title("Portfolio mensual — Paso 10 vs Paso 11")
plt.xlabel("Mes"); plt.ylabel("Suma mensual")
plt.legend()
p1 = os.path.join(FIGS_DIR, "line_portfolio_step10_vs_step11.png")
plt.tight_layout(); plt.savefig(p1); plt.show()
plt.figure(figsize=(10,4))
plt.plot(port[DATECOL], port["delta"])
plt.axhline(0)
plt.title("Portfolio mensual — Delta (Paso 11 - Paso 10)")
plt.xlabel("Mes"); plt.ylabel("Delta")
p2 = os.path.join(FIGS_DIR, "line_portfolio_delta.png")
plt.tight_layout(); plt.savefig(p2); plt.show()
# 2) Barras anuales por FM_COST_TYPE: Paso 10 vs Paso 11
by_fm_year = annual_fm.sort_values("y_step10", ascending=False).reset_index(drop=True)
x = np.arange(len(by_fm_year))
w = 0.35
plt.figure(figsize=(12,5))
plt.bar(x - w/2, by_fm_year["y_step10"], width=w, label="Paso 10 — anual")
plt.bar(x + w/2, by_fm_year["y_step11"], width=w, label="Paso 11 — anual")
plt.xticks(x, by_fm_year["FM_COST_TYPE"], rotation=45, ha="right")
plt.title("Total anual por FM_COST_TYPE — Paso 10 vs Paso 11")
plt.ylabel("Suma anual")
plt.legend()
p3 = os.path.join(FIGS_DIR, "bar_anual_fmcost_step10_vs_step11.png")
plt.tight_layout(); plt.savefig(p3); plt.show()
# 3) Boxplot de deltas a nivel bottom por FM_COST_TYPE
plt.figure(figsize=(10,5))
cats = sorted(merged["FM_COST_TYPE"].dropna().unique())
data_box = [merged.loc[merged["FM_COST_TYPE"]==k, "delta_step11_vs_step10"].values for k in cats]
if len(cats) > 0:
plt.boxplot(data_box, labels=cats, showfliers=False)
plt.title("Distribución de deltas bottom por FM_COST_TYPE (Paso 11 - Paso 10)")
plt.xlabel("FM_COST_TYPE"); plt.ylabel("Delta")
plt.xticks(rotation=45, ha="right")
p4 = os.path.join(FIGS_DIR, "box_deltas_bottom_por_fmcost.png")
plt.tight_layout(); plt.savefig(p4); plt.show()
else:
plt.text(0.5, 0.5, "No hay FM_COST_TYPE disponibles para boxplot", ha="center")
plt.show()
# 4) Top 20 ajustes absolutos bottom (barras horizontales)
tops = (merged.assign(abs_delta=merged["abs_delta"].astype(float))
.sort_values("abs_delta", ascending=False)
.head(20))
if not tops.empty:
if "PAIS" in tops.columns:
labs = tops.apply(lambda r: f"{r['ID_BUILDING']}|{r['FM_COST_TYPE']}|{str(r.get('PAIS',''))}", axis=1)
else:
labs = tops.apply(lambda r: f"{r['ID_BUILDING']}|{r['FM_COST_TYPE']}", axis=1)
plt.figure(figsize=(10,8))
plt.barh(range(len(tops)), tops["delta_step11_vs_step10"])
plt.yticks(range(len(tops)), labs)
plt.gca().invert_yaxis()
plt.title("Top 20 ajustes absolutos en bottom (Paso 11 - Paso 10)")
plt.xlabel("Delta")
p5 = os.path.join(FIGS_DIR, "barh_top20_deltas_abs.png")
plt.tight_layout(); plt.savefig(p5); plt.show()
# 5) Heatmap simple FM_COST_TYPE × mes de deltas agregados (si hay variedad)
pivot_fm = by_fm.pivot(index="FM_COST_TYPE", columns=DATECOL, values="delta") if not by_fm.empty else pd.DataFrame()
if not pivot_fm.empty:
plt.figure(figsize=(12,6))
plt.imshow(pivot_fm.values, aspect="auto")
plt.colorbar()
plt.yticks(range(pivot_fm.shape[0]), pivot_fm.index)
plt.xticks(range(pivot_fm.shape[1]), [d.strftime("%Y-%m") for d in pivot_fm.columns], rotation=45, ha="right")
plt.title("Mapa de calor — Delta mensual por FM_COST_TYPE (Paso 11 - Paso 10)")
p6 = os.path.join(FIGS_DIR, "heatmap_delta_fmcost_mes.png")
plt.tight_layout(); plt.savefig(p6); plt.show()
# 6) Scatter mensual portfolio: y_step10 vs y_step11 con línea y=x
if not port.empty:
plt.figure()
plt.scatter(port["y_step10"], port["y_step11"])
minv = float(np.nanmin([port["y_step10"].min(), port["y_step11"].min()]))
maxv = float(np.nanmax([port["y_step10"].max(), port["y_step11"].max()]))
plt.plot([minv, maxv], [minv, maxv])
plt.title("Portfolio mensual — y(P10) vs y(P11)")
plt.xlabel("Paso 10"); plt.ylabel("Paso 11")
p7 = os.path.join(FIGS_DIR, "scatter_portfolio_p10_vs_p11.png")
plt.tight_layout(); plt.savefig(p7); plt.show()
print("Listo. Guardamos las figuras en:", FIGS_DIR)
Listo. Guardamos las figuras en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_step10_vs_step11
# ============================================================
# Verificación de base Paso 10 vs Paso 11 y comparación base P10 vs reconc P11
# ============================================================
# -----------------------------
# Bloque A — Parámetros y rutas
# -----------------------------
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
os.makedirs(RUTA_REPORTING, exist_ok=True)
PATH_STEP10_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv") # generado en Paso 10
PATH_STEP11_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024_prod.csv") # generado en Paso 11
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
# --------------------------------
# Bloque B — Funciones utilitarias
# --------------------------------
def _load(path: str, required: bool=True) -> pd.DataFrame:
# Cargamos CSV con limpieza mínima de columnas
if os.path.exists(path):
df = pd.read_csv(path, sep=CSV_SEP)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
if DATECOL in df.columns:
df[DATECOL] = pd.to_datetime(df[DATECOL], errors="coerce")
if "ID_BUILDING" in df.columns:
df["ID_BUILDING"] = pd.to_numeric(df["ID_BUILDING"], errors="coerce").astype("Int64")
if "FM_COST_TYPE" in df.columns:
df["FM_COST_TYPE"] = df["FM_COST_TYPE"].astype(str).str.strip()
return df
if required:
raise FileNotFoundError(f"Falta archivo: {path}")
return pd.DataFrame()
# ---------------------------------------
# Bloque C — Carga y alineación de bases
# ---------------------------------------
p10 = _load(PATH_STEP10_RECONC, required=True)
p11 = _load(PATH_STEP11_RECONC, required=True)
# Verificamos que tengamos columnas esperadas
need10 = set(PAIR_COLS + [DATECOL, "yhat_base", "yhat_reconc"])
need11 = set(PAIR_COLS + [DATECOL, "yhat_base", "yhat_reconc"])
if not need10.issubset(set(p10.columns)):
raise KeyError(f"Paso 10 sin columnas requeridas: faltan {list(need10 - set(p10.columns))}")
if not need11.issubset(set(p11.columns)):
raise KeyError(f"Paso 11 sin columnas requeridas: faltan {list(need11 - set(p11.columns))}")
# Alineamos por clave y fecha; renombramos para distinguir
m = (p10[PAIR_COLS + [DATECOL, "yhat_base", "yhat_reconc"]]
.rename(columns={"yhat_base":"yhat_base_p10", "yhat_reconc":"yhat_reconc_p10"})
.merge(
p11[PAIR_COLS + [DATECOL, "yhat_base", "yhat_reconc"]]
.rename(columns={"yhat_base":"yhat_base_p11", "yhat_reconc":"yhat_reconc_p11"}),
on=PAIR_COLS + [DATECOL], how="inner"
))
# ---------------------------------------------
# Bloque D — Chequeo de igualdad de la “base”
# ---------------------------------------------
# Comprobamos si la base es exactamente igual (mismo universo y valores)
if m.empty:
raise ValueError("No hay intersección entre Paso 10 y Paso 11 por (ID_BUILDING, FM_COST_TYPE, FECHA).")
m["diff_base"] = (m["yhat_base_p11"] - m["yhat_base_p10"]).astype(float)
n_total = len(m)
n_iguales = int((m["diff_base"].abs() < 1e-9).sum()) # tolerancia numérica pequeña
n_distintos = n_total - n_iguales
print(f"[Base] Observaciones comparadas: {n_total}")
print(f"[Base] Iguales (|Δ|<1e-9): {n_iguales} | Distintas: {n_distintos}")
if n_distintos > 0:
print("[Base] Muestra de diferencias (5 filas):")
display(m.loc[m["diff_base"].abs()>=1e-9, PAIR_COLS + [DATECOL, "yhat_base_p10", "yhat_base_p11", "diff_base"]]
.sort_values("diff_base").head(5))
# -------------------------------------------------------
# Bloque E — Comparación base(P10) vs reconciliación(P11)
# -------------------------------------------------------
# Calculamos delta entre la base del P10 y la reconciliación del P11
m["delta_baseP10_vs_recoP11"] = (m["yhat_reconc_p11"] - m["yhat_base_p10"]).astype(float)
m["abs_delta_baseP10_vs_recoP11"] = m["delta_baseP10_vs_recoP11"].abs()
# Agregamos por portfolio mensual
portfolio = (m.groupby([DATECOL], as_index=False)
.agg(y_base_p10=("yhat_base_p10","sum"),
y_reco_p11=("yhat_reconc_p11","sum")))
portfolio["delta"] = portfolio["y_reco_p11"] - portfolio["y_base_p10"]
print("\n[Comparación] Portfolio mensual — primeras filas:")
display(portfolio.head())
# Top 20 ajustes absolutos por serie-mes entre base P10 vs reco P11
top20 = (m.sort_values("abs_delta_baseP10_vs_recoP11", ascending=False)
.head(20)[PAIR_COLS + [DATECOL, "yhat_base_p10", "yhat_reconc_p11", "delta_baseP10_vs_recoP11"]])
print("\n[Comparación] Top 20 ajustes absolutos base(P10) vs reconc(P11):")
display(top20)
[Base] Observaciones comparadas: 29148 [Base] Iguales (|Δ|<1e-9): 29148 | Distintas: 0 [Comparación] Portfolio mensual — primeras filas:
FECHA | y_base_p10 | y_reco_p11 | delta | |
---|---|---|---|---|
0 | 2024-01-01 | 3.026522e+06 | 3.026522e+06 | 0.0 |
1 | 2024-02-01 | 2.960127e+06 | 2.960127e+06 | 0.0 |
2 | 2024-03-01 | 3.008443e+06 | 3.008443e+06 | 0.0 |
3 | 2024-04-01 | 3.016421e+06 | 3.016421e+06 | 0.0 |
4 | 2024-05-01 | 3.049515e+06 | 3.049515e+06 | 0.0 |
[Comparación] Top 20 ajustes absolutos base(P10) vs reconc(P11):
ID_BUILDING | FM_COST_TYPE | FECHA | yhat_base_p10 | yhat_reconc_p11 | delta_baseP10_vs_recoP11 | |
---|---|---|---|---|---|---|
29147 | 1001488 | Servicios Extra | 2024-12-01 | 36.721350 | 36.721350 | 0.0 |
0 | 2 | Eficiencia Energética | 2024-01-01 | 0.000000 | 0.000000 | 0.0 |
1 | 2 | Eficiencia Energética | 2024-02-01 | 0.000000 | 0.000000 | 0.0 |
2 | 2 | Eficiencia Energética | 2024-03-01 | 0.000000 | 0.000000 | 0.0 |
3 | 2 | Eficiencia Energética | 2024-04-01 | 0.000000 | 0.000000 | 0.0 |
4 | 2 | Eficiencia Energética | 2024-05-01 | 0.000000 | 0.000000 | 0.0 |
5 | 2 | Eficiencia Energética | 2024-06-01 | 0.000000 | 0.000000 | 0.0 |
6 | 2 | Eficiencia Energética | 2024-07-01 | 0.000000 | 0.000000 | 0.0 |
7 | 2 | Eficiencia Energética | 2024-08-01 | 0.000000 | 0.000000 | 0.0 |
8 | 2 | Eficiencia Energética | 2024-09-01 | 0.000000 | 0.000000 | 0.0 |
9 | 2 | Eficiencia Energética | 2024-10-01 | 0.000000 | 0.000000 | 0.0 |
10 | 2 | Eficiencia Energética | 2024-11-01 | 0.000000 | 0.000000 | 0.0 |
29131 | 1001488 | Mtto. Correctivo | 2024-08-01 | 836.077700 | 836.077700 | 0.0 |
29130 | 1001488 | Mtto. Correctivo | 2024-07-01 | 836.077700 | 836.077700 | 0.0 |
29129 | 1001488 | Mtto. Correctivo | 2024-06-01 | 963.295649 | 963.295649 | 0.0 |
29128 | 1001488 | Mtto. Correctivo | 2024-05-01 | 836.077700 | 836.077700 | 0.0 |
29127 | 1001488 | Mtto. Correctivo | 2024-04-01 | 836.077700 | 836.077700 | 0.0 |
29126 | 1001488 | Mtto. Correctivo | 2024-03-01 | 950.468331 | 950.468331 | 0.0 |
29125 | 1001488 | Mtto. Correctivo | 2024-02-01 | 836.077700 | 836.077700 | 0.0 |
29124 | 1001488 | Mtto. Correctivo | 2024-01-01 | 836.077700 | 836.077700 | 0.0 |
Realizar una reconciliacion sin referencia no tiene demasiado sentido, porque el resultado equivale a la base de predicción realizada con la modelización. Comentado con el equipo de FM de FerMar SL, nos han dicho que ellos de base siempre usan el último año disponible (en la ITE1 el año 2023) y lo multiplican por un factor esperado que es un IPC global e igual para todos los paises.
En este supuesto, vamos a tomar el real 2023 a nivel agregado (portfolio, FM_COST_TYPE
o país
/segmento).
Le aplicamos un multiplicador IPC x País que equivale al último publicado por el banco mundial para los paises y en su defecto, tomamos el de defecto (por ejemplo: 1.03 si esperamos un +3% de IPC).
Ese valor se convierte en nuestro “c” en la ecuación de reconciliación:
c = IPC x real_2023
El Paso 11 reparte las diferencias necesarias para que la suma de los bottoms cuadre con ese agregado proyectado.
Ventajas
Tenemos reconciliación que ajusta, no es un passthrough.
Introducimos conocimiento del negocio (ejemplo: inflación esperada, crecimiento esperado de costes).
Es reproducible y no dependemos del real 2024.
Limitaciones
El ajuste dependerá totalmente del supuesto del multiplicador.
Si el IPC u otro índice no es representativo de todos los FM_COST_TYPE, podríamos distorsionar la reconciliación.
Cuándo usarlo
Paso 10: en desarrollo o retrospectiva, cuando tenemos la verdad 2024 y queremos validar mejoras.
Paso 11: en pre-producción o producción, cuando necesitamos un output consistente pero no hay verdad. Aquí encaja perfecto usar 2023 escalado por IPC como ancla. También se podría usar un incremento % para cada tipo de coste y país.
Extraemos los valores únicos de la variable Pais en el dataset dim_building.csv para acotar la búsqueda en la OCDE de los IPC de cada país para el mes y año del último registro del dataset de entrenamiento.
# ============================================================
# Extracción de países únicos desde dim_buildings.csv
# ============================================================
# -------------------------------
# Bloque 0) Parámetros y rutas
# -------------------------------
# Definimos las rutas coherentes con el proyecto
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
os.makedirs(RUTA_METRICAS, exist_ok=True)
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
PATH_OUT = os.path.join(RUTA_METRICAS, "paises_unicos_detectados.csv")
# ------------------------------------------
# Bloque 1) Helpers (normalización simple)
# ------------------------------------------
def _norm_str(s):
"""Normalizamos strings: convertimos a str, recortamos y vacíos a NaN."""
if s is None or (isinstance(s, float) and np.isnan(s)):
return np.nan
s = str(s).strip()
return np.nan if s == "" else s
# -------------------------------
# Bloque 2) Carga y extracción
# -------------------------------
# Leemos dim_buildings y localizamos la columna de país.
if not os.path.exists(PATH_DIM):
raise FileNotFoundError(f"No encontramos {PATH_DIM}. Asegurémonos de ejecutar el Paso 9 antes.")
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP)
# Intentamos detectar la columna de país con nombres comunes
cols_up = {c.upper().strip(): c for c in dim.columns}
if "PAIS" in cols_up:
col_pais = cols_up["PAIS"]
elif "COUNTRY" in cols_up:
col_pais = cols_up["COUNTRY"]
elif "COUNTRY_DEF" in cols_up:
col_pais = cols_up["COUNTRY_DEF"]
else:
raise KeyError("No encontramos columna de país (PAIS/COUNTRY/COUNTRY_DEF) en dim_buildings.csv")
# Obtenemos países únicos normalizados
paises = (
dim[[col_pais]]
.rename(columns={col_pais: "PAIS"})
.assign(PAIS=lambda d: d["PAIS"].map(_norm_str))
.dropna(subset=["PAIS"])
.drop_duplicates()
.sort_values("PAIS")
.reset_index(drop=True)
)
# Guardamos y mostramos
paises.to_csv(PATH_OUT, sep=CSV_SEP, index=False)
print(f"[Info] Países únicos detectados: {len(paises)}")
print("[Info] Los listamos uno por línea para que nos los puedas pegar tal cual:")
for p in paises["PAIS"].tolist():
print(p)
print(f"\n[Info] También los guardamos en: {PATH_OUT}")
[Info] Países únicos detectados: 13 [Info] Los listamos uno por línea para que nos los puedas pegar tal cual: COLOMBIA COSTA RICA DESCONOCIDO ESPAÑA ITALIA MALTA MARRUECOS MÉXICO PANAMÁ PERÚ PUERTO RICO REPÚBLICA DOMINICANA VENEZUELA [Info] También los guardamos en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/paises_unicos_detectados.csv
Pendiente - Obviamos DESCONOCIDO y se lo trasladamos al equipo de FM para que revisen el catálogo.¶
Búsqueda de IPCs paises en web - PENDIENTE REVISAR ACCESO API OCDE¶
# ============================================================
# Construcción de ipc_por_pais_fechaRef.csv
# ============================================================
# - Obtenemos países únicos desde METRICAS/dim_buildings.csv (columna PAIS)
# - Deducimos fecha_ref = mes anterior al último FECHA del TRAIN
# - Consultamos IMF SDMX JSON (dataset PCPI_IX, frecuencia mensual)
# - Calculamos IPC interanual (YoY %) en fecha_ref por país
# - Guardamos METRICAS/ipc_por_pais_fechaRef.csv
# ============================================================
# -----------------------------
# Bloque 0) Imports y constantes
# -----------------------------
# Rutas base del proyecto (ajustamos a tu estructura)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
os.makedirs(RUTA_RESULTADOS, exist_ok=True)
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv") # o el TRAIN vigente
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
OUT_IPC = os.path.join(RUTA_METRICAS, "ipc_por_pais_fechaRef.csv")
LOG_PATH = os.path.join(RUTA_LOGS, "ipc_fetch.log")
# IMF SDMX (PCPI_IX mensual). Usamos CompactData para respuesta más simple.
IMF_BASE = "https://dataservices.imf.org/REST/SDMX_JSON.svc/CompactData"
IMF_DATASET = "PCPI_IX" # Índice de Precios al Consumidor (2010=100 ó base IMF), mensual
# CompactData estructura: /CompactData/PCPI_IX/M.<REF_AREA>.PCPI_IX?startPeriod=YYYY-MM&endPeriod=YYYY-MM
# Mapeo de nombres de país (en dim_buildings) → REF_AREA IMF (ISO2)
# Afinamos con códigos ISO2 estándar, que el IMF suele usar como REF_AREA
PAIS_TO_IMF_REF = {
"COLOMBIA": "CO",
"COSTA RICA": "CR",
"ESPAÑA": "ES",
"ITALIA": "IT",
"MALTA": "MT",
"MARRUECOS": "MA",
"MÉXICO": "MX",
"MEXICO": "MX",
"PANAMÁ": "PA",
"PANAMA": "PA",
"PERÚ": "PE",
"PERU": "PE",
"PUERTO RICO": "PR",
"REPÚBLICA DOMINICANA": "DO",
"REPUBLICA DOMINICANA": "DO",
"VENEZUELA": "VE",
# “DESCONOCIDO” lo omitimos explícitamente más abajo
}
# -----------------------------
# Bloque 1) Logging y utilidades
# -----------------------------
def log_ipc(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
# Normalizamos FECHA al primer día de mes para evitar desalineaciones
df = df.copy()
df[datecol] = pd.to_datetime(df[datecol]).dt.to_period("M").dt.to_timestamp()
return df
def _read_csv(path: str, required: bool=True, sep: str=CSV_SEP) -> pd.DataFrame:
if os.path.exists(path):
df = pd.read_csv(path, sep=sep)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta el archivo requerido: {path}")
log_ipc(f"AVISO: no encontramos {os.path.basename(path)} (continuamos sin este artefacto).")
return pd.DataFrame()
def _prev_month(ts: pd.Timestamp) -> pd.Timestamp:
# Restamos un mes respetando frecuencia mensual
ts = pd.Timestamp(ts).to_period("M").to_timestamp()
return (ts - pd.offsets.MonthBegin(1))
def _as_ym(ts: pd.Timestamp) -> str:
# Devolvemos YYYY-MM
return pd.Timestamp(ts).strftime("%Y-%m")
# -----------------------------
# Bloque 2) Países únicos desde dim_buildings y fecha_ref desde TRAIN
# -----------------------------
def _get_unique_paises_from_dim(path_dim: str) -> List[str]:
dim = _read_csv(path_dim, required=True)
# Normalizamos nombre de la columna y de valores
cols_upper = {c.upper(): c for c in dim.columns}
if "PAIS" not in cols_upper:
raise KeyError("No encontramos columna 'PAIS' en dim_buildings.csv")
pais_col = cols_upper["PAIS"]
paises = (dim[pais_col]
.astype(str)
.str.strip()
.str.upper()
.replace({"": np.nan, "NAN": np.nan}))
# Omitimos explícitamente DESCONOCIDO
out = sorted([p for p in paises.dropna().unique().tolist() if p != "DESCONOCIDO"])
return out
def _get_fecha_ref_from_train(path_train: str, datecol: str="FECHA") -> pd.Timestamp:
train = _read_csv(path_train, required=True)
if datecol not in train.columns:
# Intentamos alternativas comunes
cand = [c for c in train.columns if c.strip().upper() in ("FECHA","DATE","PERIOD","DS")]
if not cand:
raise KeyError(f"No encontramos columna de fecha en TRAIN (esperábamos '{datecol}')")
datecol = cand[0]
train = _ensure_ms(train, datecol)
last_ts = pd.to_datetime(train[datecol]).max()
# Usamos el mes anterior al último del TRAIN, como definimos en el requerimiento
fecha_ref = _prev_month(last_ts)
return fecha_ref
# -----------------------------
# Bloque 3) Consulta IMF SDMX (PCPI_IX) y cálculo YoY
# -----------------------------
def _imf_compact_series(ref_area: str, start_ym: str, end_ym: str) -> Optional[Dict[str, Any]]:
# Construimos endpoint: CompactData/PCPI_IX/M.<REF_AREA>.PCPI_IX?startPeriod=YYYY-MM&endPeriod=YYYY-MM
url = f"{IMF_BASE}/{IMF_DATASET}/M.{ref_area}.PCPI_IX?startPeriod={start_ym}&endPeriod={end_ym}"
try:
r = requests.get(url, timeout=30)
r.raise_for_status()
return r.json()
except Exception as e:
log_ipc(f"ERROR IMF fetch {ref_area}: {e}")
return None
def _parse_compact_to_ts(json_obj: Dict[str, Any]) -> pd.DataFrame:
# Extraemos pares (time,value) desde CompactData → DataSet → Series → Obs
# Devolvemos DataFrame con columnas: DATE(YYYY-MM-01), VALUE(float)
try:
ds = json_obj.get("CompactData", {}).get("DataSet", {})
series = ds.get("Series")
if series is None:
return pd.DataFrame(columns=["DATE","VALUE"])
# En CompactData, Series puede ser dict o list; normalizamos a lista
if isinstance(series, dict):
series = [series]
rows = []
for s in series:
obs = s.get("Obs", [])
if isinstance(obs, dict):
obs = [obs]
for o in obs:
t = o.get("@TIME_PERIOD")
v = o.get("@OBS_VALUE")
if t is None or v is None:
continue
# Convertimos YYYY-MM a timestamp MS
try:
dt = pd.to_datetime(f"{t}-01")
val = float(v)
rows.append((dt, val))
except Exception:
continue
if not rows:
return pd.DataFrame(columns=["DATE","VALUE"])
df = pd.DataFrame(rows, columns=["DATE","VALUE"]).sort_values("DATE")
return df
except Exception:
return pd.DataFrame(columns=["DATE","VALUE"])
def _compute_yoy_at(df: pd.DataFrame, fecha_ref: pd.Timestamp) -> Tuple[Optional[float], Optional[float], Optional[float]]:
# Calculamos valor en fecha_ref y en fecha_ref - 12m; devolvemos (val_ref, val_lag12, yoy_pct)
if df.empty:
return (None, None, None)
df = df.copy()
df["DATE"] = pd.to_datetime(df["DATE"]).dt.to_period("M").dt.to_timestamp()
ref = fecha_ref.to_period("M").to_timestamp()
lag = (ref - pd.offsets.DateOffset(years=1)).to_period("M").to_timestamp()
v_ref = df.loc[df["DATE"].eq(ref), "VALUE"]
v_lag = df.loc[df["DATE"].eq(lag), "VALUE"]
if v_ref.empty or v_lag.empty:
return (float(v_ref.iloc[0]) if not v_ref.empty else None,
float(v_lag.iloc[0]) if not v_lag.empty else None,
None)
a = float(v_ref.iloc[0]); b = float(v_lag.iloc[0])
if b == 0 or not math.isfinite(a) or not math.isfinite(b):
return (a, b, None)
yoy = 100.0 * (a / b - 1.0)
return (a, b, yoy)
# -----------------------------
# Bloque 4) Flujo principal
# -----------------------------
def main_build_ipc_csv_no_pandasdmx(
path_dim: str = PATH_DIM,
path_train: str = PATH_TRAIN,
out_csv: str = OUT_IPC
):
# 1) Países únicos desde dim_buildings
paises = _get_unique_paises_from_dim(path_dim)
log_ipc(f"Paises únicos en dim_buildings (sin 'DESCONOCIDO'): {paises}")
# 2) Fecha de referencia desde TRAIN (mes anterior al último)
fecha_ref = _get_fecha_ref_from_train(path_train, datecol="FECHA")
ref_ym = _as_ym(fecha_ref)
# Para robustez, pedimos un rango amplio que cubra al menos ref y ref-12
start_ym = _as_ym((fecha_ref - pd.offsets.DateOffset(years=1)) - pd.offsets.MonthBegin(1))
end_ym = ref_ym
log_ipc(f"Fecha de referencia para IPC (YoY): {ref_ym} (lag de 12 meses incluido desde {start_ym})")
# 3) Iteramos países → IMF → YoY
rows = []
for pais in paises:
pais_norm = pais.strip().upper()
ref_area = PAIS_TO_IMF_REF.get(pais_norm)
if ref_area is None:
rows.append({
"PAIS": pais_norm,
"REF_AREA_IMF": None,
"FECHA_REF": ref_ym,
"CPI_VALUE": None,
"CPI_VALUE_LAG12": None,
"IPC_YOY_PCT": None,
"STATUS": "SIN_MAPEO_REF_AREA"
})
continue
js = _imf_compact_series(ref_area, start_ym, end_ym)
if js is None:
rows.append({
"PAIS": pais_norm,
"REF_AREA_IMF": ref_area,
"FECHA_REF": ref_ym,
"CPI_VALUE": None,
"CPI_VALUE_LAG12": None,
"IPC_YOY_PCT": None,
"STATUS": "ERROR_FETCH"
})
continue
df = _parse_compact_to_ts(js)
val_ref, val_lag, yoy = _compute_yoy_at(df, fecha_ref)
status = "OK" if yoy is not None else ("FALTAN_MESES" if (val_ref is None or val_lag is None) else "DIV_ZERO_O_NAN")
rows.append({
"PAIS": pais_norm,
"REF_AREA_IMF": ref_area,
"FECHA_REF": ref_ym,
"CPI_VALUE": val_ref,
"CPI_VALUE_LAG12": val_lag,
"IPC_YOY_PCT": yoy,
"STATUS": status
})
log_ipc(f"{pais_norm}: {status} | YoY={yoy}")
# 4) Guardamos CSV
out = pd.DataFrame(rows, columns=["PAIS","REF_AREA_IMF","FECHA_REF","CPI_VALUE","CPI_VALUE_LAG12","IPC_YOY_PCT","STATUS"])
out.to_csv(out_csv, sep=CSV_SEP, index=False)
log_ipc(f"Guardado: {out_csv} con {len(out)} filas.")
# -----------------------------
# Bloque 5) Ejecutamos
# -----------------------------
main_build_ipc_csv_no_pandasdmx()
[2025-09-26T06:04:38] Paises únicos en dim_buildings (sin 'DESCONOCIDO'): ['COLOMBIA', 'COSTA RICA', 'ESPAÑA', 'ITALIA', 'MALTA', 'MARRUECOS', 'MÉXICO', 'PANAMÁ', 'PERÚ', 'PUERTO RICO', 'REPÚBLICA DOMINICANA', 'VENEZUELA'] [2025-09-26T06:04:38] Fecha de referencia para IPC (YoY): 2023-11 (lag de 12 meses incluido desde 2022-10) [2025-09-26T06:05:08] ERROR IMF fetch CO: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.CO.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7ed156d0>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:05:38] ERROR IMF fetch CR: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.CR.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae81ec70e0>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:06:08] ERROR IMF fetch ES: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.ES.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae82e465a0>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:06:38] ERROR IMF fetch IT: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.IT.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae81dae060>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:07:08] ERROR IMF fetch MT: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.MT.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7eeb7e90>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:07:38] ERROR IMF fetch MA: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.MA.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7eeb5640>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:08:08] ERROR IMF fetch MX: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.MX.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae81dafc80>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:08:38] ERROR IMF fetch PA: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.PA.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae82e46a80>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:09:08] ERROR IMF fetch PE: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.PE.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7ee692e0>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:09:39] ERROR IMF fetch PR: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.PR.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae8203fad0>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:10:09] ERROR IMF fetch DO: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.DO.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7eebaa20>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:10:39] ERROR IMF fetch VE: HTTPSConnectionPool(host='dataservices.imf.org', port=443): Max retries exceeded with url: /REST/SDMX_JSON.svc/CompactData/PCPI_IX/M.VE.PCPI_IX?startPeriod=2022-10&endPeriod=2023-11 (Caused by ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x7eae7d30c170>, 'Connection to dataservices.imf.org timed out. (connect timeout=30)')) [2025-09-26T06:10:39] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/ipc_por_pais_fechaRef.csv con 12 filas.
El proceso automático via API nos falla y no tenemos mucho tiempo para automatizarlo, así que proponemos parchear con un diccionario manual y lo dejamos para una iteración posterior.
Vamos a buscar los IPC en la web para el año 2023 de manera manual
Vamos a buscar en web el IPC interanual (diciembre 2023 vs. diciembre 2022) y, al lado, la fuente oficial:
País | IPC interanual dic-2023 | Fuente oficial
Colombia |9,28 % |DANE, boletín de 9-ene-2024. www.dane.gov.co
Costa Rica |-1,77 % |INEC, nota del 8-ene-2024 (y su PDF técnico). INEC
España |3,1 % |INE, nota de prensa (y PDF). INE
Italia |0,6 % |ISTAT, comunicado “Prezzi al consumo - Dicembre 2023”. Istat
Malta |3,7 % (HICP) |NSO Malta, “Harmonised Index of Consumer Prices - December 2023”. NSO Malta
Marruecos |3,4 % |Bank Al-Maghrib, “Inflation et inflation sous-jacente - Décembre 2023”. (Dato oficial del banco central; el HCP publica el parte mensual sin la tasa interanual explícita). Bkam
México |4,66 % |INEGI, comunicado del 9-ene-2024 (PDF y nota web). INEGI
Panamá |1,9 % |INEC Panamá, comentario técnico de dic-2023. www.inec.gob.pa
Perú |3,41 % (nacional) — 3,24 % (Lima Metropolitana) | INEI, notas oficiales. Radio Nacional.
Puerto Rico (ver nota) El DTRH publica el IPC oficial, pero no hay un boletín público específico con la tasa interanual de diciembre 2023 fácilmente accesible; existe el portal oficial que nos permite acceder al inventario de series para localizar el PDF mensual correspondiente. www.mercadolaboral.pr.gov Como no hay predicciones, no lo vamos a buscar.
República Dominicana |3,57 % |BCRD, nota del 3-ene-2024 y reporte “IPC diciembre 2023”. www.bancentral.gov.do / www.cdn.bancentral.gov.do
Venezuela |189,8 % |Cifra oficial del BCV comunicada públicamente (enlace directo del BCV no disponible; la recogen agencias como Reuters citando al BCV).
Al hacer lo mismo para diciembre 2024 ya tenemos una tabla resumen que si nos falla el bloque anterior podemos asignar como fallback del proceso.
Detectamos los países relevantes Empezamos mirando la lista de países que tenemos en la dimensión de edificios y descartamos los que no tienen país definido. Así sabemos exactamente para qué países tenemos que calcular un IPC.
Elegimos el mes de referencia El mes que nos interesa no lo fijamos nosotros manualmente: lo deducimos automáticamente del histórico de entrenamiento. Tomamos el último mes disponible en ese histórico y retrocedemos uno. Por ejemplo, si el último mes del train es agosto 2025, nuestro mes de referencia será julio 2025.
Creamos una tabla de trabajo Para cada país generamos una fila donde ponemos su nombre y el mes de referencia. Esa es la base sobre la que añadiremos la información del IPC.
Parcheamos con los valores de diciembre conocidos Tenemos una tabla manual con los IPC de diciembre de 2023 y 2024. Para cada país y cada año de la tabla base, miramos si existe un valor de diciembre en nuestra tabla manual. Si existe, lo añadimos como “IPC de diciembre de ese año” en una columna auxiliar.
Asignamos el IPC mensual
Si el mes de referencia es diciembre y tenemos un valor de diciembre para ese país, usamos directamente ese valor.
Si el mes de referencia no es diciembre y no tenemos ningún otro valor para ese mes, usamos igualmente el valor de diciembre del mismo año como una estimación anual. En ese caso marcamos la fila como “estimada”.
- Opción de forzar siempre diciembre Si queremos, podemos activar una regla más estricta: “aunque el mes de referencia no sea diciembre o tengamos otro dato, siempre que exista un diciembre para ese año, usamos ese valor”. Eso homogeneiza todo al anual de diciembre y lo dejamos marcado como “override”.
Resultado final
Conseguimos que para cada país y cada mes de referencia tengamos siempre un IPC disponible:
a veces el real de diciembre,
a veces una estimación anual tomada de diciembre,
y cuando decidimos forzar, siempre el de diciembre aunque haya otro dato. Además, cada fila queda etiquetada con un estado (“directo”, “estimado”, “override” o “missing”), de modo que siempre sabemos de dónde salió el valor.
# ============================================================
# IPC mensual (YoY %) por país — sin internet, con parche diciembre
# ============================================================
# En este bloque:
# - Leemos países únicos desde dim_buildings.csv (excluimos DESCONOCIDO)
# - Inferimos el mes objetivo a partir de la última fecha del train (mes-1)
# - Creamos una tabla mensual de IPC YoY por país
# - Aplicamos un parche con los IPC de diciembre 2023/2024 que nos diste
# - Opción: forzar siempre el IPC anual de diciembre del mismo año
# - Guardamos METRICAS/ipc_monthly_lookup.csv
# ============================================================
# ------------------------------
# Bloque 0 · Parámetros y rutas
# ------------------------------
# Definimos base del proyecto (ajustar si fuera necesario)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
os.makedirs(RUTA_RESULTADOS, exist_ok=True)
os.makedirs(RUTA_METRICAS, exist_ok=True)
os.makedirs(RUTA_LOGS, exist_ok=True)
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # entrada
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv") # entrada (puede ser 2021..2025)
OUT_IPC_CSV = os.path.join(RUTA_METRICAS, "ipc_monthly_lookup.csv") # salida
LOG_PATH = os.path.join(RUTA_LOGS, "ipc_monthly_build.log")
def log_ipc(msg: str):
# Registramos mensajes en consola y en un log simple
stamp = datetime.now().isoformat(timespec="seconds")
line = f"[{stamp}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
# -----------------------------------------------------
# Bloque 1 · Tabla de IPC de diciembre (parche manual)
# -----------------------------------------------------
# Aquí ponemos los valores que nos diste. Mantendremos una estructura ampliable
# por años. Los valores están en porcentaje (p.ej. 3.10 = 3.10%).
IPC_DICIEMBRE = {
2023: {
"COLOMBIA": 9.28,
"COSTA RICA": -1.77,
"ESPAÑA": 3.10,
"ITALIA": 0.60,
"MALTA": 3.70,
"MARRUECOS": 3.40,
"MÉXICO": 4.66,
"PANAMÁ": 1.90,
"PERÚ": 3.24,
"PUERTO RICO": 3.10,
"REPÚBLICA DOMINICANA": 3.57,
"VENEZUELA": 189.80
},
2024: {
"COLOMBIA": 3.33,
"COSTA RICA": 1.96,
"ESPAÑA": 2.80,
"ITALIA": 1.30,
"MALTA": 1.80,
"MARRUECOS": 0.70,
"MÉXICO": 4.21,
"PANAMÁ": -0.10,
"PERÚ": 1.97,
"PUERTO RICO": 1.90,
"REPÚBLICA DOMINICANA": 3.35,
"VENEZUELA": 189.80
},
# Lo dejamos preparado para añadir 2025 y futuros años
# 2025: { "PAIS": valor, ... }
}
# Bandera de operación: si la activamos, forzamos SIEMPRE diciembre del año correspondiente
FORZAR_DICIEMBRE_SIEMPRE = False # podemos activar cuando queramos
# -------------------------------------------
# Bloque 2 · Helpers de fechas y normalizado
# -------------------------------------------
def _ensure_ms(dt_series: pd.Series) -> pd.Series:
# Normalizamos una serie de fechas al primer día de mes (frecuencia MS)
s = pd.to_datetime(dt_series)
return s.dt.to_period("M").dt.to_timestamp()
def _ult_mes_menos_1(df: pd.DataFrame, datecol: str) -> pd.Timestamp:
# Obtenemos el último mes del train, lo normalizamos a MS, y restamos un mes
if df.empty or datecol not in df.columns:
raise ValueError("No podemos determinar la fecha objetivo: el train está vacío o falta la columna FECHA.")
fechas = _ensure_ms(df[datecol])
last = fechas.max()
# Restamos un mes (si last es 2025-08-01 => objetivo 2025-07-01)
objetivo = (last.to_period("M") - 1).to_timestamp()
return objetivo
def _build_base_table(paises: list[str], target_month: pd.Timestamp) -> pd.DataFrame:
# Creamos una tabla base con una fila por país y el mes objetivo
out = pd.DataFrame({
"PAIS": paises,
"FECHA": [target_month] * len(paises),
})
out["ANIO"] = out["FECHA"].dt.year
out["MES"] = out["FECHA"].dt.month
# Inicializamos columnas de IPC y trazabilidad
out["IPC_YOY_PCT"] = np.nan
out["IPC_YOY_PCT_DECEMBER"] = np.nan
out["ESTIMADO_DEC_ANUAL"] = 0
out["STATUS"] = "INIT"
out["FUENTE_IPC"] = "INIT"
return out
def _apply_december_patch(df: pd.DataFrame) -> pd.DataFrame:
# Aplicamos el parche de diciembre 2023/2024 a la columna IPC_YOY_PCT_DECEMBER
df = df.copy()
# Asignamos el diciembre anual disponible para el ANIO de cada fila
mask_year_in_patch = df["ANIO"].isin(IPC_DICIEMBRE.keys())
for idx in df.index[mask_year_in_patch]:
year = int(df.at[idx, "ANIO"])
pais = str(df.at[idx, "PAIS"]).upper().strip()
if pais in IPC_DICIEMBRE.get(year, {}):
df.at[idx, "IPC_YOY_PCT_DECEMBER"] = float(IPC_DICIEMBRE[year][pais])
return df
def _fill_from_december_when_needed(df: pd.DataFrame) -> pd.DataFrame:
# Rellenamos IPC_YOY_PCT con el diciembre anual si el mes objetivo no es diciembre
# y si hay valor disponible
df = df.copy()
is_december_row = df["MES"].eq(12)
# Caso 1: si el mes objetivo ES diciembre y tenemos valor, usamos directamente diciembre
mask_use_direct_dec = is_december_row & df["IPC_YOY_PCT_DECEMBER"].notna()
df.loc[mask_use_direct_dec, "IPC_YOY_PCT"] = df.loc[mask_use_direct_dec, "IPC_YOY_PCT_DECEMBER"]
df.loc[mask_use_direct_dec, "STATUS"] = "OK_DEC_DIRECT"
df.loc[mask_use_direct_dec, "FUENTE_IPC"] = "USER_DECEMBER"
df.loc[mask_use_direct_dec, "ESTIMADO_DEC_ANUAL"] = 0
# Caso 2: si el mes objetivo NO es diciembre, estimamos con diciembre del mismo año si existe
mask_estimate = (~is_december_row) & df["IPC_YOY_PCT"].isna() & df["IPC_YOY_PCT_DECEMBER"].notna()
df.loc[mask_estimate, "IPC_YOY_PCT"] = df.loc[mask_estimate, "IPC_YOY_PCT_DECEMBER"]
df.loc[mask_estimate, "STATUS"] = "OK_EST_DEC"
df.loc[mask_estimate, "FUENTE_IPC"] = "USER_DECEMBER"
df.loc[mask_estimate, "ESTIMADO_DEC_ANUAL"] = 1
return df
def _force_december_override(df: pd.DataFrame) -> pd.DataFrame:
# Si activamos la bandera, sobreescribimos SIEMPRE con diciembre del mismo año cuando exista
df = df.copy()
if FORZAR_DICIEMBRE_SIEMPRE:
mask_force = df["IPC_YOY_PCT_DECEMBER"].notna()
df.loc[mask_force, "IPC_YOY_PCT"] = df.loc[mask_force, "IPC_YOY_PCT_DECEMBER"]
df.loc[mask_force, "STATUS"] = "OK_OVERRIDE_DEC"
df.loc[mask_force, "FUENTE_IPC"] = "USER_DECEMBER"
df.loc[mask_force, "ESTIMADO_DEC_ANUAL"] = 1
log_ipc(f"Forzamos diciembre anual en {int(mask_force.sum())} filas (FORZAR_DICIEMBRE_SIEMPRE=True).")
else:
log_ipc("No forzamos diciembre anual (FORZAR_DICIEMBRE_SIEMPRE=False).")
return df
# ---------------------------------------------------------
# Bloque 3 · Cargamos entradas y detectamos mes objetivo
# ---------------------------------------------------------
# 3.1 Países únicos desde dim_buildings
if not os.path.exists(PATH_DIM):
raise FileNotFoundError(f"No encontramos dim_buildings en {PATH_DIM}")
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP)
if "PAIS" not in dim.columns:
raise KeyError("Esperábamos columna 'PAIS' en dim_buildings.csv")
paises_all = (dim["PAIS"].astype(str).str.strip().str.upper()
.replace({"": np.nan})
.dropna()
.unique()
.tolist())
paises = [p for p in paises_all if p != "DESCONOCIDO"]
log_ipc(f"Paises únicos en dim_buildings (sin 'DESCONOCIDO'): {paises}")
# 3.2 Mes objetivo desde train (último mes - 1)
if not os.path.exists(PATH_TRAIN):
raise FileNotFoundError(f"No encontramos train en {PATH_TRAIN}")
train = pd.read_csv(PATH_TRAIN, sep=CSV_SEP)
if "FECHA" not in train.columns:
raise KeyError("Esperábamos columna 'FECHA' en train.")
# Normalizamos la fecha
train["FECHA"] = _ensure_ms(train["FECHA"])
target_month = _ult_mes_menos_1(train, "FECHA")
log_ipc(f"Mes objetivo IPC (YoY) inferido: {target_month.strftime('%Y-%m')} (último train - 1 mes)")
# ---------------------------------------------------------
# Bloque 4 · Construcción y parche del IPC mensual
# ---------------------------------------------------------
# 4.1 Base por país
ipc_df = _build_base_table(paises, target_month)
# 4.2 Parche: valores de diciembre disponibles
ipc_df = _apply_december_patch(ipc_df)
# 4.3 Relleno: si no es diciembre, usamos diciembre del mismo año (estimación)
ipc_df = _fill_from_december_when_needed(ipc_df)
# 4.4 Opción: forzar diciembre siempre (si activamos la bandera)
ipc_df = _force_december_override(ipc_df)
# 4.5 Completar trazabilidad por defecto donde no asignamos IPC
mask_empty = ipc_df["IPC_YOY_PCT"].isna()
ipc_df.loc[mask_empty, "STATUS"] = "MISSING"
ipc_df.loc[mask_empty, "FUENTE_IPC"] = ipc_df.loc[mask_empty, "FUENTE_IPC"].replace("INIT", "NO_SOURCE")
# Orden de columnas y tipos
cols_order = [
"PAIS", "FECHA", "ANIO", "MES",
"IPC_YOY_PCT", "IPC_YOY_PCT_DECEMBER",
"ESTIMADO_DEC_ANUAL", "STATUS", "FUENTE_IPC"
]
ipc_df = ipc_df[cols_order].sort_values(["PAIS", "FECHA"]).reset_index(drop=True)
# Guardamos CSV
ipc_df.to_csv(OUT_IPC_CSV, sep=CSV_SEP, index=False)
log_ipc(f"Guardado: {OUT_IPC_CSV} con {len(ipc_df)} filas.")
# ---------------------------------------------------------
# Bloque 5 · Resumen operativo
# ---------------------------------------------------------
n_ok = int((ipc_df["STATUS"].isin(["OK_DEC_DIRECT","OK_EST_DEC","OK_OVERRIDE_DEC"])).sum())
n_miss= int((ipc_df["STATUS"]=="MISSING").sum())
log_ipc(f"Resumen — filas OK={n_ok} | faltantes={n_miss}")
# Mostramos una vista rápida
display(ipc_df.head(10))
[2025-09-26T05:11:00] Paises únicos en dim_buildings (sin 'DESCONOCIDO'): ['ESPAÑA', 'PERÚ', 'PANAMÁ', 'MÉXICO', 'COSTA RICA', 'COLOMBIA', 'ITALIA', 'REPÚBLICA DOMINICANA', 'MALTA', 'PUERTO RICO', 'MARRUECOS', 'VENEZUELA'] [2025-09-26T05:11:00] Mes objetivo IPC (YoY) inferido: 2023-11 (último train - 1 mes) [2025-09-26T05:11:00] No forzamos diciembre anual (FORZAR_DICIEMBRE_SIEMPRE=False). [2025-09-26T05:11:00] Guardado: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/ipc_monthly_lookup.csv con 12 filas. [2025-09-26T05:11:00] Resumen — filas OK=12 | faltantes=0
PAIS | FECHA | ANIO | MES | IPC_YOY_PCT | IPC_YOY_PCT_DECEMBER | ESTIMADO_DEC_ANUAL | STATUS | FUENTE_IPC | |
---|---|---|---|---|---|---|---|---|---|
0 | COLOMBIA | 2023-11-01 | 2023 | 11 | 9.28 | 9.28 | 1 | OK_EST_DEC | USER_DECEMBER |
1 | COSTA RICA | 2023-11-01 | 2023 | 11 | -1.77 | -1.77 | 1 | OK_EST_DEC | USER_DECEMBER |
2 | ESPAÑA | 2023-11-01 | 2023 | 11 | 3.10 | 3.10 | 1 | OK_EST_DEC | USER_DECEMBER |
3 | ITALIA | 2023-11-01 | 2023 | 11 | 0.60 | 0.60 | 1 | OK_EST_DEC | USER_DECEMBER |
4 | MALTA | 2023-11-01 | 2023 | 11 | 3.70 | 3.70 | 1 | OK_EST_DEC | USER_DECEMBER |
5 | MARRUECOS | 2023-11-01 | 2023 | 11 | 3.40 | 3.40 | 1 | OK_EST_DEC | USER_DECEMBER |
6 | MÉXICO | 2023-11-01 | 2023 | 11 | 4.66 | 4.66 | 1 | OK_EST_DEC | USER_DECEMBER |
7 | PANAMÁ | 2023-11-01 | 2023 | 11 | 1.90 | 1.90 | 1 | OK_EST_DEC | USER_DECEMBER |
8 | PERÚ | 2023-11-01 | 2023 | 11 | 3.24 | 3.24 | 1 | OK_EST_DEC | USER_DECEMBER |
9 | PUERTO RICO | 2023-11-01 | 2023 | 11 | 3.10 | 3.10 | 1 | OK_EST_DEC | USER_DECEMBER |
Cómo lo interpretamos nosotros después de ejecutar
Si vemos STATUS=OK_DEC_DIRECT, entendemos que hemos usado el diciembre del mismo año porque el mes objetivo era diciembre.
Si vemos STATUS=OK_EST_DEC, entendemos que el mes objetivo no era diciembre y lo estimamos con el anual de diciembre del mismo año (ESTIMADO_DEC_ANUAL=1).
Si activamos FORZAR_DICIEMBRE_SIEMPRE=True, veremos STATUS=OK_OVERRIDE_DEC y asumimos que siempre usamos diciembre cuando existe, homogeneizando el IPC mensual al anual.
Si aparece STATUS=MISSING, no teníamos diciembre en nuestro diccionario para ese año/país; en ese caso, o ampliamos IPC_DICIEMBRE con nuevos años o desactivamos el forzado y aportamos otra fuente cuando la tengamos.
Paso 12 - Reconciliación como el Paso 11 (producción) pero con referencia real 2023 + IPC obtenido (volvemos a dar pesos distintos segun MASE12).¶
El Paso 12 es como el Paso 11 (producción sin verdad de 2024), pero usando anclas construidas a partir del real 2023 escalado por el IPC del fichero que ya generaste (METRICAS/ipc_monthly_lookup.csv). Abajo tienes un script completo listo para pegar que:
Lee las predicciones bottom (yhat base),
Lee dim_buildings (PAIS/REGION),
Toma el histórico 2023 real (de train_full_2021_2023.csv, columna cost_float_mod),
Lee el IPC (col IPC_YOY_PCT) de ipc_monthly_lookup.csv y escala 2023 → objetivo anual 2024 por segmento,
Distribuye ese objetivo anual por mes proporcional a la estacionalidad del propio pronóstico base (si el share es nulo, reparte uniforme),
Construye anclas mensuales para 3 niveles: FM_COST_TYPE, FM_COST_TYPE|PAIS, FM_COST_TYPE|REGION,
Ejecuta MinT (WLS) con A (solo agregados) y W (diagonal por bottom) y clipea bottom a ≥ 0,
Deja salidas en RESULTADOS/ y REPORTING/ con comparativos estructurales.
Si no hay IPC para un país, asumimos 0% (no inflaciona/deflaciona).
Si no hay real 2023 para un segmento, no generamos ancora (ese segmento caerá en autoconsistencia c = A y_b).
Después de un par de iteraciones, vemos necesario incluir una opción ACTIVE
que realiza un ajuste para los pares cuya base sea 0. En este caso, proponemos congelar estos pares y solo aplicar la conciliación en el resto.
# ============================================================
# PASO 12 (PRODUCCIÓN con anclas)
# Reconciliación MinT usando:
# - Real 2023 por segmento (desde TRAIN 2021-2023)
# - IPC (YoY %) por país desde METRICAS/ipc_monthly_lookup.csv
# - Distribución mensual proporcional al share del pronóstico base
# - Robustez con W distinto a identidad
# con referencias o anclas (TOP desde FM×PAIS y REGION por share base)
# y un export para auditoría
# Salidas:
# RESULTADOS/preds_reconciliadas_step12.csv
# REPORTING/compare_estructural_step12.csv
# REPORTING/figs_step12/*.png
# ============================================================
# ============================================================
# Robustez con W distinto a identidad
# con referencias o anclas (TOP desde FM×PAIS y REGION por share base)
# y un export para auditoría.
# dos versiones de reconciliación la estandar STD y la ACTIVE
# ============================================================
# --- Parámetros/Rutas (ajusta si hace falta) ---
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
RUTA_LOGS = os.path.join(ruta_base_3, "LOGS")
for _d in [RUTA_RESULTADOS, RUTA_METRICAS, RUTA_REPORTING, RUTA_LOGS]:
os.makedirs(_d, exist_ok=True)
# Entradas principales
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv") # base 2024 por serie
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv") # PAIS/REGION
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv") # real 2023
PATH_IPC = os.path.join(RUTA_METRICAS, "ipc_monthly_lookup.csv") # IPC YoY % por país (mes objetivo)
PATH_PASO8 = os.path.join(RUTA_METRICAS, "paso8_metrics_2024_por_pareja.csv")# para W MASE12²
# Salidas
OUT_PREDS_RECONC = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv")
# OUT_PREDS_RECONC_BEFORE = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_beforeclip.csv")
OUT_COMPARE = os.path.join(RUTA_REPORTING, "compare_estructural_step12.csv")
FIGS_DIR = os.path.join(RUTA_REPORTING, "figs_step12")
os.makedirs(FIGS_DIR, exist_ok=True)
# Export anclas (auditoría)
TOP_CSV = os.path.join(RUTA_METRICAS, "target_top_month_step12.csv")
FMP_CSV = os.path.join(RUTA_METRICAS, "target_fmp_month_step12.csv")
FMR_CSV = os.path.join(RUTA_METRICAS, "target_fmr_month_step12.csv")
# Claves/Frecuencia
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
VALCOL_PRED = "yhat_combo"
MONTHS_2024 = pd.date_range("2024-01-01", periods=12, freq="MS")
EPS = 1e-8
def log12(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(os.path.join(RUTA_LOGS, "estrategia3_step12.log"), "a", encoding="utf-8") as f:
f.write(line + "\n")
def _ensure_ms(df: pd.DataFrame, datecol: str) -> pd.DataFrame:
out = df.copy()
out[datecol] = pd.to_datetime(out[datecol]).dt.to_period("M").dt.to_timestamp()
return out
def _read_csv(path: str, required=True) -> pd.DataFrame:
if os.path.exists(path):
df = pd.read_csv(path, sep=CSV_SEP)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
if required:
raise FileNotFoundError(f"Falta archivo requerido: {path}")
log12(f"AVISO: no existe {path}, seguimos.")
return pd.DataFrame()
def _norm_pair_keys(df: pd.DataFrame) -> pd.DataFrame:
if df is None or df.empty: return df
out = df.copy()
out.columns = [str(c).strip() for c in out.columns]
if "ID_BUILDING" in out.columns:
try:
out["ID_BUILDING"] = pd.to_numeric(out["ID_BUILDING"], errors="coerce").astype("Int64")
except Exception:
pass
if "FM_COST_TYPE" in out.columns:
out["FM_COST_TYPE"] = out["FM_COST_TYPE"].astype(str).str.strip()
return out
# ------------------------------
# 0) Carga entradas
# ------------------------------
log12("Paso 12 — Inicio.")
preds_df = _ensure_ms(_norm_pair_keys(_read_csv(PATH_PREDS, required=True)), DATECOL)
if VALCOL_PRED not in preds_df.columns:
raise KeyError(f"Esperábamos columna {VALCOL_PRED} en {os.path.basename(PATH_PREDS)}.")
keep_pred_cols = [c for c in [*PAIR_COLS, DATECOL, VALCOL_PRED, "route", "fallback_flag"] if c in preds_df.columns]
preds_df = preds_df[keep_pred_cols].copy()
# agregamos por si hay duplicados de (pair,mes)
preds_df = (preds_df.groupby(PAIR_COLS+[DATECOL], as_index=False)
.agg({VALCOL_PRED:"sum",
**({"route":"last"} if "route" in preds_df.columns else {}),
**({"fallback_flag":"max"} if "fallback_flag" in preds_df.columns else {})}))
dim_df = _norm_pair_keys(_read_csv(PATH_DIM, required=True))
ipc_df = _read_csv(PATH_IPC, required=True)
train_df = _ensure_ms(_norm_pair_keys(_read_csv(PATH_TRAIN, required=True)), DATECOL)
# normalizamos dim (solo lo necesario)
dim_keep = [c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim_df.columns]
dim_slim = dim_df[dim_keep].drop_duplicates() if len(dim_keep) else pd.DataFrame(columns=["ID_BUILDING","PAIS","REGION"])
# ------------------------------
# 1) Metadatos jerárquicos
# ------------------------------
bottom_df = preds_df.merge(dim_slim, on="ID_BUILDING", how="left")
keys_bottom = bottom_df[PAIR_COLS].drop_duplicates().sort_values(PAIR_COLS).reset_index(drop=True)
keys_top = bottom_df[["FM_COST_TYPE"]].drop_duplicates().dropna().reset_index(drop=True)
keys_fm_pais = bottom_df[["FM_COST_TYPE","PAIS"]].dropna().drop_duplicates().reset_index(drop=True)
keys_fm_reg = bottom_df[["FM_COST_TYPE","REGION"]].dropna().drop_duplicates().reset_index(drop=True)
n_bottom = len(keys_bottom)
log12(f"Nodos: bottom={n_bottom} | top={len(keys_top)} | fm×pais={len(keys_fm_pais)} | fm×region={len(keys_fm_reg)}")
# helpers de hijos
def _child_idx_for_top(fm):
mask = (keys_bottom["FM_COST_TYPE"]==fm)
return keys_bottom.index[mask].tolist()
def _child_idx_for_fm_pais(fm, pais):
sub = bottom_df.merge(keys_bottom, on=PAIR_COLS, how="inner")
sub = sub[(sub["FM_COST_TYPE"]==fm) & (sub["PAIS"]==pais)]
return keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
def _child_idx_for_fm_region(fm, region):
sub = bottom_df.merge(keys_bottom, on=PAIR_COLS, how="inner")
sub = sub[(sub["FM_COST_TYPE"]==fm) & (sub["REGION"]==region)]
return keys_bottom.merge(sub[PAIR_COLS].drop_duplicates(), on=PAIR_COLS, how="inner").index.tolist()
# ------------------------------
# 2) Matriz A (agregados)
# Orden: TOP → FM×PAIS → FM×REGION
# ------------------------------
def _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_reg):
rows = []
# TOP
if not keys_top.empty:
for (fm,) in keys_top.itertuples(index=False, name=None):
idxs = _child_idx_for_top(fm)
row = np.zeros(n_bottom, dtype=float)
if idxs: row[np.asarray(idxs, dtype=int)] = 1.0
rows.append(row)
# FM×PAIS
if not keys_fm_pais.empty:
for (fm, pais) in keys_fm_pais.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_pais(fm, pais)
row = np.zeros(n_bottom, dtype=float)
if idxs: row[np.asarray(idxs, dtype=int)] = 1.0
rows.append(row)
# FM×REGION
if not keys_fm_reg.empty:
for (fm, region) in keys_fm_reg.itertuples(index=False, name=None):
idxs = _child_idx_for_fm_region(fm, region)
row = np.zeros(n_bottom, dtype=float)
if idxs: row[np.asarray(idxs, dtype=int)] = 1.0
rows.append(row)
if len(rows)==0:
A = np.zeros((0, n_bottom), dtype=float)
else:
A = np.vstack(rows)
return A
A = _build_A_matrix(n_bottom, keys_top, keys_fm_pais, keys_fm_reg)
log12(f"Matriz A: shape={A.shape}")
# ------------------------------
# 3) W diagonal (MASE12² de Paso 8; fallback identidad)
# ------------------------------
W_diag = np.ones(n_bottom, dtype=float) # fallback
try:
if os.path.exists(PATH_PASO8):
paso8 = pd.read_csv(PATH_PASO8, sep=CSV_SEP)
paso8.columns = [str(c).strip() for c in paso8.columns]
if "vista" in paso8.columns:
paso8 = paso8[paso8["vista"].astype(str).str.lower().eq("all_months")]
assert set(PAIR_COLS).issubset(paso8.columns) and ("MASE12" in paso8.columns)
# mapa MASE
mase_map = {
(int(pd.to_numeric(r[PAIR_COLS[0]], errors="coerce")), str(r[PAIR_COLS[1]]).strip()):
float(pd.to_numeric(r["MASE12"], errors="coerce"))
for _, r in paso8[[*PAIR_COLS,"MASE12"]].drop_duplicates(PAIR_COLS).iterrows()
}
cnt_mase = 0
for i, (bid, ctype) in enumerate(keys_bottom[PAIR_COLS].itertuples(index=False, name=None)):
key = (int(bid) if pd.notna(bid) else bid, str(ctype).strip())
m = mase_map.get(key, np.nan)
if np.isfinite(m):
W_diag[i] = max(m**2, 1e-8)
cnt_mase += 1
else:
W_diag[i] = 1.0
log12(f"W diagonal: MASE12² disponibles={cnt_mase} | identidad={n_bottom - cnt_mase}")
else:
log12(f"AVISO: no existe {PATH_PASO8}. W=identidad.")
except Exception as e:
log12(f"AVISO: no se pudo construir W desde paso8 ({e}). W=identidad.")
W_inv_diag = 1.0 / np.maximum(W_diag, 1e-8)
# ------------------------------
# 4) Anclas anuales (FM×PAIS) y mensualización por share base
# - real 2023 desde TRAIN (sum 2023)
# - factor IPC por país (último IPC en ipc_monthly_lookup)
# - target anual = real_2023 * (1 + IPC/100)
# - reparto mensual por shares del pronóstico base (sin IPC extra)
# - TOP = suma por FM,mes; REGION = share dentro de FM×PAIS
# ------------------------------
# real 2023 por FM×PAIS
train_with_dim = train_df.merge(dim_slim, on="ID_BUILDING", how="left")
real_2023_fmp = (train_with_dim[(train_with_dim[DATECOL]>=pd.Timestamp("2023-01-01")) &
(train_with_dim[DATECOL]<=pd.Timestamp("2023-12-01"))]
.dropna(subset=["PAIS"])
.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["cost_float_mod"].sum()
.rename(columns={"cost_float_mod":"real_2023"}))
# IPC país (tomamos el último registro por país del CSV)
ipc_df[DATECOL] = pd.to_datetime(ipc_df[DATECOL], errors="coerce")
ipc_latest = (ipc_df.sort_values(["PAIS", DATECOL])
.groupby("PAIS", as_index=False).tail(1)[["PAIS","IPC_YOY_PCT"]])
ipc_latest["factor"] = 1.0 + pd.to_numeric(ipc_latest["IPC_YOY_PCT"], errors="coerce").fillna(0.0)/100.0
# target anual fmp = real_2023 * factor
target_annual_fmp = (real_2023_fmp
.merge(ipc_latest[["PAIS","factor"]], on="PAIS", how="left")
.assign(factor=lambda d: d["factor"].fillna(1.0))
.assign(target_annual=lambda d: d["real_2023"] * d["factor"]))
# shares mensuales desde el base 2024 por FM×PAIS×mes
preds_with_dim = preds_df.merge(dim_slim, on="ID_BUILDING", how="left")
base_fmp_month = (preds_with_dim.dropna(subset=["PAIS"])
.groupby(["FM_COST_TYPE","PAIS",DATECOL], as_index=False)[VALCOL_PRED]
.sum().rename(columns={VALCOL_PRED:"base_sum"}))
sum_base_fmp = (base_fmp_month.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["base_sum"].sum()
.rename(columns={"base_sum":"base_sum_annual"}))
shares_fmp = base_fmp_month.merge(sum_base_fmp, on=["FM_COST_TYPE","PAIS"], how="left")
shares_fmp["share"] = shares_fmp["base_sum"] / shares_fmp["base_sum_annual"].replace(0.0, np.nan)
# target_fmp_month = share * target_annual
target_fmp_month = (shares_fmp
.merge(target_annual_fmp[["FM_COST_TYPE","PAIS","target_annual"]],
on=["FM_COST_TYPE","PAIS"], how="left"))
target_fmp_month["target"] = target_fmp_month["share"].fillna(0.0) * target_fmp_month["target_annual"].fillna(0.0)
target_fmp_month = target_fmp_month[["FM_COST_TYPE","PAIS",DATECOL,"target"]].copy()
# TOP mensual = suma de fmp por FM,mes
target_top_month = (target_fmp_month.groupby(["FM_COST_TYPE",DATECOL], as_index=False)["target"].sum())
# FM×REGION: share mensual dentro de cada (FM,PAIS,mes) sobre base
base_fmr_month = (preds_with_dim.dropna(subset=["PAIS","REGION"])
.groupby(["FM_COST_TYPE","PAIS","REGION",DATECOL], as_index=False)[VALCOL_PRED]
.sum().rename(columns={VALCOL_PRED:"base_sum"}))
shares_fmr = base_fmr_month.merge(
base_fmp_month.rename(columns={"base_sum":"base_sum_fmp"}),
on=["FM_COST_TYPE","PAIS",DATECOL], how="left"
)
shares_fmr["share"] = shares_fmr["base_sum"] / shares_fmr["base_sum_fmp"].replace(0.0, np.nan)
target_fmr_month = shares_fmr.merge(
target_fmp_month.rename(columns={"target":"target_fmp"}),
on=["FM_COST_TYPE","PAIS",DATECOL], how="left"
)
target_fmr_month["target"] = target_fmr_month["share"].fillna(0.0) * target_fmr_month["target_fmp"].fillna(0.0)
target_fmr_month = target_fmr_month[["FM_COST_TYPE","PAIS","REGION",DATECOL,"target"]].copy()
# ------------------------------
# 5) Export anclas para auditoría
# ------------------------------
topexp = target_top_month.copy()
topexp["nivel"] = "FM_COST_TYPE"
topexp["clave"] = topexp["FM_COST_TYPE"].astype(str)
topexp = topexp[["nivel","clave",DATECOL,"target"]].sort_values([DATECOL,"clave"])
topexp.to_csv(TOP_CSV, sep=CSV_SEP, index=False)
fmpexp = target_fmp_month.copy()
fmpexp["nivel"] = "FM_COST_TYPE|PAIS"
fmpexp["clave"] = fmpexp["FM_COST_TYPE"].astype(str) + "|" + fmpexp["PAIS"].astype(str)
fmpexp = fmpexp[["nivel","clave",DATECOL,"target"]].sort_values([DATECOL,"clave"])
fmpexp.to_csv(FMP_CSV, sep=CSV_SEP, index=False)
fmrexp = target_fmr_month.copy()
fmrexp["nivel"] = "FM_COST_TYPE|REGION"
fmrexp["clave"] = fmrexp["FM_COST_TYPE"].astype(str) + "|" + fmrexp["REGION"].astype(str)
fmrexp = fmrexp[["nivel","clave",DATECOL,"target"]].sort_values([DATECOL,"clave"])
fmrexp.to_csv(FMR_CSV, sep=CSV_SEP, index=False)
log12(f"Exportadas anclas mensuales usadas: {TOP_CSV} | {FMP_CSV} | {FMR_CSV}")
# ============================================================
# 6) Reconciliación P12 — variante estándar y variante SOLO ACTIVOS
# - estándar: reconcilia todos los bottom
# - solo_activos: congela pares con base <= ZERO_THR
# Guardamos ANTES y DESPUÉS del clip para ambas variantes
# ============================================================
ZERO_THR = 1e-9 # umbral para decidir “cero estructural”
# Salidas específicas de variantes
OUT_P12_STD = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv") # mantiene nombre histórico
OUT_P12_STD_BEFORE = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_beforeclip.csv")
OUT_P12_ACTIVE = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly.csv")
OUT_P12_ACTIVE_BEFORE= os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly_beforeclip.csv")
# Comparativas
OUT_CMP_BOTTOM = os.path.join(RUTA_REPORTING, "compare_step12_std_vs_active_bottom.csv")
OUT_CMP_PORT = os.path.join(RUTA_REPORTING, "compare_step12_std_vs_active_portfolio.csv")
OUT_FIG_DIR = os.path.join(RUTA_REPORTING, "figs_step12_compare_active")
os.makedirs(OUT_FIG_DIR, exist_ok=True)
def _yb_from_preds(month_ts):
sub = preds_df[preds_df[DATECOL].eq(month_ts)]
merged = keys_bottom.merge(sub, on=PAIR_COLS, how="left")
return merged[VALCOL_PRED].fillna(0.0).values.astype(float)
def _c_from_targets(month_ts):
m = pd.Timestamp(month_ts).to_period("M").to_timestamp()
parts = []
# TOP
if not keys_top.empty:
want = (keys_top.assign(**{DATECOL:m})
.merge(target_top_month.rename(columns={"target":"t"}),
on=["FM_COST_TYPE",DATECOL], how="left")
.sort_values(["FM_COST_TYPE"]))
parts.append(want["t"].fillna(0.0).values.astype(float))
# FM×PAIS
if not keys_fm_pais.empty:
want = (keys_fm_pais.assign(**{DATECOL:m})
.merge(target_fmp_month.rename(columns={"target":"t"}),
on=["FM_COST_TYPE","PAIS",DATECOL], how="left")
.sort_values(["FM_COST_TYPE","PAIS"]))
parts.append(want["t"].fillna(0.0).values.astype(float))
# FM×REGION
if not keys_fm_reg.empty:
want = (keys_fm_reg.assign(**{DATECOL:m})
.merge(target_fmr_month.rename(columns={"target":"t"}),
on=["FM_COST_TYPE","REGION",DATECOL], how="left")
.sort_values(["FM_COST_TYPE","REGION"]))
parts.append(want["t"].fillna(0.0).values.astype(float))
if len(parts)==0:
return np.zeros(0, dtype=float)
return np.concatenate(parts, axis=0)
def _mint_reconcile_full(yb, c):
"""Reconciliación estándar sobre todos los nodos."""
if A.shape[0]==0 or yb.size==0:
y_pre = yb.copy()
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
AW = A * W_diag
AWAT = AW @ A.T
try:
AWAT_inv = np.linalg.pinv(AWAT, rcond=1e-10)
except Exception:
AWAT_inv = np.linalg.pinv(AWAT + 1e-8*np.eye(AWAT.shape[0]))
r = c - (A @ yb)
v = AWAT_inv @ r
delta = (A.T * W_diag[:, None]) @ v
y_pre = yb + delta
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
def _mint_reconcile_activeonly(yb, c, zero_thr=ZERO_THR):
"""
Reconciliación SOLO en nodos activos S = {i: yb_i > zero_thr}.
F (resto) se fija. Relaja filas sin hijos activos.
"""
m = yb.size
if A.shape[0] == 0 or m == 0:
y_pre = yb.copy()
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
active_mask = (yb > zero_thr)
idx_S = np.where(active_mask)[0]
idx_F = np.where(~active_mask)[0]
if idx_S.size == 0:
y_pre = yb.copy()
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
A_S = A[:, idx_S]
A_F = A[:, idx_F] if idx_F.size>0 else np.zeros((A.shape[0],0))
yS = yb[idx_S]
yF = yb[idx_F]
W_S = W_diag[idx_S]
r = c - (A_S @ yS) - (A_F @ yF)
if A_S.size > 0:
no_dof = (np.abs(A_S).sum(axis=1) <= 0)
if np.any(no_dof):
r = r.copy()
r[no_dof] = 0.0
if A_S.size == 0:
y_pre = yb.copy()
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
AW = A_S * W_S
AWAT = AW @ A_S.T
try:
AWAT_inv = np.linalg.pinv(AWAT, rcond=1e-10)
except Exception:
AWAT_inv = np.linalg.pinv(AWAT + 1e-8*np.eye(AWAT.shape[0]))
v = AWAT_inv @ r
deltaS = (A_S.T * W_S[:, None]) @ v
y_pre = yb.copy()
y_pre[idx_S] = yS + deltaS
y_pos = np.maximum(y_pre, 0.0)
return y_pre, y_pos
def _run_variant(name, reconcile_fn, out_after, out_before):
rows_after, rows_before = [], []
for mth in MONTHS_2024:
yb = _yb_from_preds(mth)
c = _c_from_targets(mth)
y_pre, y_pos = reconcile_fn(yb, c)
df_after = keys_bottom.copy()
df_after[DATECOL] = mth
df_after["yhat_base"] = yb
df_after["yhat_reconc"] = y_pos
rows_after.append(df_after)
df_before = keys_bottom.copy()
df_before[DATECOL] = mth
df_before["yhat_base"] = yb
df_before["yhat_reconc"] = y_pre
rows_before.append(df_before)
after = (pd.concat(rows_after, ignore_index=True)
.sort_values(PAIR_COLS+[DATECOL]).reset_index(drop=True))
before = (pd.concat(rows_before, ignore_index=True)
.sort_values(PAIR_COLS+[DATECOL]).reset_index(drop=True))
after.to_csv(out_after, sep=CSV_SEP, index=False)
before.to_csv(out_before, sep=CSV_SEP, index=False)
negs = int((after["yhat_reconc"] < 0).sum())
log12(f"[{name}] Guardado AFTER: {out_after} ({len(after)} filas) | negativos={negs}")
log12(f"[{name}] Guardado BEFORE: {out_before} ({len(before)} filas)")
return after, before
log12(f"Meses a reconciliar: {len(MONTHS_2024)} ({MONTHS_2024[0].date()} .. {MONTHS_2024[-1].date()})")
# Variante estándar (todos los nodos)
p12_std_after, p12_std_before = _run_variant(
name="P12_STD",
reconcile_fn=_mint_reconcile_full,
out_after=OUT_P12_STD,
out_before=OUT_P12_STD_BEFORE
)
# Variante solo-activos (congelando base≈0)
p12_act_after, p12_act_before = _run_variant(
name="P12_ACTIVE",
reconcile_fn=lambda yb, c: _mint_reconcile_activeonly(yb, c, ZERO_THR),
out_after=OUT_P12_ACTIVE,
out_before=OUT_P12_ACTIVE_BEFORE
)
# ------------------------------
# 8) Figuras rápidas (self-contained, no depende de variables previas)
# ------------------------------
try:
import matplotlib.pyplot as plt
# Aseguramos un DataFrame base para las figuras
_std_df = p12_std_after if 'p12_std_after' in locals() else pd.read_csv(OUT_P12_STD, sep=CSV_SEP, parse_dates=[DATECOL])
# Histograma de deltas bottom (STD)
tmp_fig = _std_df.copy()
tmp_fig["delta"] = tmp_fig["yhat_reconc"] - tmp_fig["yhat_base"]
plt.figure(figsize=(8,5))
tmp_fig["delta"].plot(kind="hist", bins=40)
plt.title("Paso 12 (STD) — Deltas bottom (yhat_reconc - yhat_base)")
plt.xlabel("Delta"); plt.ylabel("Frecuencia")
plt.tight_layout()
plt.savefig(os.path.join(FIGS_DIR, "hist_deltas_bottom_step12.png"))
plt.close()
# Serie portfolio (STD)
port_fig = (tmp_fig
.groupby(DATECOL, as_index=False)
.agg(y_base=("yhat_base","sum"), y_reco=("yhat_reconc","sum")))
plt.figure(figsize=(10,5))
plt.plot(port_fig[DATECOL], port_fig["y_base"], label="Base")
plt.plot(port_fig[DATECOL], port_fig["y_reco"], label="Reconc P12 (STD)")
plt.title("Paso 12 — Portfolio mensual: Base vs Reconciliado (STD)")
plt.xlabel("Mes"); plt.ylabel("Suma mensual"); plt.legend()
plt.tight_layout()
plt.savefig(os.path.join(FIGS_DIR, "line_portfolio_base_vs_reco_step12.png"))
plt.close()
# Boxplot por FM_COST_TYPE de deltas (STD)
box_df = tmp_fig.groupby("FM_COST_TYPE")["delta"].apply(list).to_dict()
if len(box_df) > 0:
plt.figure(figsize=(10,5))
plt.boxplot(list(box_df.values()), labels=list(box_df.keys()), showfliers=False)
plt.xticks(rotation=45, ha="right")
plt.title("Paso 12 (STD) — Deltas por FM_COST_TYPE")
plt.ylabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(FIGS_DIR, "box_deltas_por_fmcost_step12.png"))
plt.close()
log12(f"Figuras en {FIGS_DIR}")
except Exception as e:
log12(f"AVISO: no fue posible generar figuras (bloque 8): {e}")
# ------------------------------
# 9) Resumen final robusto
# ------------------------------
# Asegura preds_reconc para el conteo de negativos
if 'p12_std_after' in locals():
preds_reconc = p12_std_after.copy()
else:
preds_reconc = pd.read_csv(OUT_P12_STD, sep=CSV_SEP, parse_dates=[DATECOL])
neg_counts = int((preds_reconc["yhat_reconc"] < 0).sum())
log12(f"Valores negativos en yhat_reconc (STD): {neg_counts} (esperado 0 por clip)")
log12("Paso 12 finalizado.")
[2025-09-27T08:25:30] Paso 12 — Inicio. [2025-09-27T08:25:31] Nodos: bottom=2429 | top=8 | fm×pais=49 | fm×region=573 [2025-09-27T08:25:52] Matriz A: shape=(630, 2429) [2025-09-27T08:25:53] W diagonal: MASE12² disponibles=2171 | identidad=258 [2025-09-27T08:25:53] Exportadas anclas mensuales usadas: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/target_top_month_step12.csv | /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/target_fmp_month_step12.csv | /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/target_fmr_month_step12.csv [2025-09-27T08:25:53] Meses a reconciliar: 12 (2024-01-01 .. 2024-12-01) [2025-09-27T08:26:05] [P12_STD] Guardado AFTER: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_step12.csv (29148 filas) | negativos=0 [2025-09-27T08:26:05] [P12_STD] Guardado BEFORE: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_step12_beforeclip.csv (29148 filas) [2025-09-27T08:26:17] [P12_ACTIVE] Guardado AFTER: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_step12_activeonly.csv (29148 filas) | negativos=0 [2025-09-27T08:26:17] [P12_ACTIVE] Guardado BEFORE: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/RESULTADOS/preds_reconciliadas_step12_activeonly_beforeclip.csv (29148 filas) [2025-09-27T08:26:18] Figuras en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_step12 [2025-09-27T08:26:18] Valores negativos en yhat_reconc (STD): 0 (esperado 0 por clip) [2025-09-27T08:26:18] Paso 12 finalizado.
Revisión de la coherencia¶
Vamos a verifica que la reconciliación ha tenido sentido.
Primero, miramos las sumas mes a mes y al año para cada combinación de FM x PAIS. Para ello usamos como referencia las anclas que realmente hemos aplicado en el paso 12. Y si esas anclas no están disponibles, usamos un cálculo más aproximado: la base multiplicada por el IPC.
Segundo, repetimos estos chequeos tanto para la versión estándar (STD) como, si tenemos el fichero guardado, también para la variante de solo activos (ACTIVE). Así vemos si ambas cuadran bien con los objetivos.
Tercero, comprobamos si después del “clip” (el paso donde forzamos a que no haya negativos) han quedado valores por debajo de cero. Y, si además guardamos los ficheros *_beforeclip.csv, podemos revisar también lo que pasaba antes del clip.
Por último, este bloque es bastante independiente: no necesita que tengamos objetos ya cargados en memoria porque siempre vuelve a leer los datos de los ficheros en disco, con las mismas rutas y nombres de columnas que venimos usando.
# === Post-checks Paso 12 (coherencia y diagnóstico, STD vs ACTIVE) ===
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
DATECOL = "FECHA"
# Entradas generadas por el paso 12 (nombres del bloque actualizado)
PATH_STD_AFTER = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv")
PATH_ACTIVE_AFTER = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly.csv")
PATH_STD_BEFORE = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_beforeclip.csv") # opcional
PATH_ACT_BEFORE = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly_beforeclip.csv") # opcional
# Carga principal
std = pd.read_csv(PATH_STD_AFTER, sep=CSV_SEP, parse_dates=[DATECOL])
dim = pd.read_csv(os.path.join(RUTA_METRICAS, "dim_buildings.csv"), sep=CSV_SEP)
ipc = pd.read_csv(os.path.join(RUTA_METRICAS, "ipc_monthly_lookup.csv"), sep=CSV_SEP, parse_dates=["FECHA"])
# Variante ACTIVE (si existe)
active_exists = os.path.exists(PATH_ACTIVE_AFTER)
if active_exists:
act = pd.read_csv(PATH_ACTIVE_AFTER, sep=CSV_SEP, parse_dates=[DATECOL])
# 1) Coherencia FM_COST_TYPE×PAIS mensual — base vs reconc (STD y, si existe, ACTIVE)
df_std = std.merge(dim[["ID_BUILDING","PAIS"]], on="ID_BUILDING", how="left")
g_base_std = df_std.groupby(["FM_COST_TYPE","PAIS",DATECOL], as_index=False)["yhat_base"].sum()
g_reco_std = df_std.groupby(["FM_COST_TYPE","PAIS",DATECOL], as_index=False)["yhat_reconc"].sum()
cmp_fmp_std = g_base_std.merge(g_reco_std, on=["FM_COST_TYPE","PAIS",DATECOL])
cmp_fmp_std["delta"] = cmp_fmp_std["yhat_reconc"] - cmp_fmp_std["yhat_base"]
print(" Dispersión de delta (FM×PAIS mensual) — VARIANTE ESTÁNDAR:")
print(cmp_fmp_std["delta"].describe())
if active_exists:
df_act = act.merge(dim[["ID_BUILDING","PAIS"]], on="ID_BUILDING", how="left")
g_reco_act = df_act.groupby(["FM_COST_TYPE","PAIS",DATECOL], as_index=False)["yhat_reconc"].sum()
cmp_fmp_act = g_base_std.merge(g_reco_act, on=["FM_COST_TYPE","PAIS",DATECOL])
cmp_fmp_act["delta"] = cmp_fmp_act["yhat_reconc"] - cmp_fmp_act["yhat_base"]
print("\n Dispersión de delta (FM×PAIS mensual) — VARIANTE SOLO ACTIVOS:")
print(cmp_fmp_act["delta"].describe())
# 2) Gap contra objetivo ANUAL (preferimos ANCLA usada). Leemos anclas exportadas en el Paso 12.
path_fmp_targets = os.path.join(RUTA_METRICAS, "target_fmp_month_step12.csv")
ann_base = g_base_std.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["yhat_base"] \
.sum().rename(columns={"yhat_base":"y_base_annual"})
ann_reco_std = g_reco_std.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["yhat_reconc"] \
.sum().rename(columns={"yhat_reconc":"y_reco_annual_std"})
cmp_ann = ann_base.merge(ann_reco_std, on=["FM_COST_TYPE","PAIS"], how="outer")
if active_exists:
ann_reco_act = g_reco_act.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["yhat_reconc"] \
.sum().rename(columns={"yhat_reconc":"y_reco_annual_active"})
cmp_ann = cmp_ann.merge(ann_reco_act, on=["FM_COST_TYPE","PAIS"], how="left")
use_ipc_factor = False
if os.path.exists(path_fmp_targets):
t = pd.read_csv(path_fmp_targets, sep=CSV_SEP, parse_dates=[DATECOL])
# El export del Paso 12 tiene columnas: nivel, clave, FECHA, target
expected = {"nivel","clave",DATECOL,"target"}
if expected.issubset(set(t.columns)):
t = t[t["nivel"].astype(str).eq("FM_COST_TYPE|PAIS")].copy()
# reconstruimos FM_COST_TYPE y PAIS desde 'clave'
parts = t["clave"].astype(str).str.split("|", n=1, expand=True)
t["FM_COST_TYPE"] = parts[0]
t["PAIS"] = parts[1]
ann_target = t.groupby(["FM_COST_TYPE","PAIS"], as_index=False)["target"].sum() \
.rename(columns={"target":"target_annual"})
cmp_ann = cmp_ann.merge(ann_target, on=["FM_COST_TYPE","PAIS"], how="left")
# Gaps vs ancla usada
cmp_ann["gap_std_vs_target"] = cmp_ann["y_reco_annual_std"] - cmp_ann["target_annual"]
cmp_ann["gap_std_rel_%"] = 100.0 * cmp_ann["gap_std_vs_target"] / cmp_ann["target_annual"].replace(0, np.nan)
if active_exists:
cmp_ann["gap_active_vs_target"] = cmp_ann["y_reco_annual_active"] - cmp_ann["target_annual"]
cmp_ann["gap_active_rel_%"] = 100.0 * cmp_ann["gap_active_vs_target"] / cmp_ann["target_annual"].replace(0, np.nan)
print("\n Top 10 gaps relativos FM×PAIS (anual) — vs ANCLA real (VARIANTE ESTÁNDAR):")
print(cmp_ann.sort_values("gap_std_rel_%", ascending=False).head(10)
[["FM_COST_TYPE","PAIS","y_reco_annual_std","target_annual","gap_std_rel_%"]])
if active_exists:
print("\n Top 10 gaps relativos FM×PAIS (anual) — vs ANCLA real (VARIANTE SOLO ACTIVOS):")
print(cmp_ann.sort_values("gap_active_rel_%", ascending=False).head(10)
[["FM_COST_TYPE","PAIS","y_reco_annual_active","target_annual","gap_active_rel_%"]])
else:
print("\n[AVISO] target_fmp_month_step12.csv no tiene el esquema esperado; haremos fallback a base×IPC.")
use_ipc_factor = True
else:
use_ipc_factor = True
# Fallback: aproximación base×factor (último IPC por país)
if use_ipc_factor:
ipc_latest = (ipc.sort_values(["PAIS","FECHA"])
.groupby("PAIS", as_index=False).tail(1)[["PAIS","IPC_YOY_PCT"]])
ipc_latest["factor"] = 1.0 + pd.to_numeric(ipc_latest["IPC_YOY_PCT"], errors="coerce").fillna(0.0)/100.0
cmp_ann = cmp_ann.merge(ipc_latest[["PAIS","factor"]], on="PAIS", how="left").fillna({"factor":1.0})
cmp_ann["target_annual_aprox"] = cmp_ann["y_base_annual"] * cmp_ann["factor"]
cmp_ann["gap_std_vs_target"] = cmp_ann["y_reco_annual_std"] - cmp_ann["target_annual_aprox"]
cmp_ann["gap_std_rel_%"] = 100.0 * cmp_ann["gap_std_vs_target"] / cmp_ann["target_annual_aprox"].replace(0, np.nan)
cols_show = ["FM_COST_TYPE","PAIS","y_reco_annual_std","target_annual_aprox","gap_std_rel_%"]
print("\n Top 10 gaps relativos FM×PAIS (anual) — referencia con target ≈ base×IPC (STD):")
print(cmp_ann.sort_values("gap_std_rel_%", ascending=False).head(10)[cols_show])
if active_exists:
cmp_ann["gap_active_vs_target"] = cmp_ann["y_reco_annual_active"] - cmp_ann["target_annual_aprox"]
cmp_ann["gap_active_rel_%"] = 100.0 * cmp_ann["gap_active_vs_target"] / cmp_ann["target_annual_aprox"].replace(0, np.nan)
cols_show_act = ["FM_COST_TYPE","PAIS","y_reco_annual_active","target_annual_aprox","gap_active_rel_%"]
print("\n Top 10 gaps relativos FM×PAIS (anual) — referencia con target ≈ base×IPC (ACTIVE):")
print(cmp_ann.sort_values("gap_active_rel_%", ascending=False).head(10)[cols_show_act])
# 3) Coherencia portfolio: diferencia mensual total antes vs después (STD y ACTIVE)
port_std_b = std.groupby(DATECOL, as_index=False)["yhat_base"].sum().rename(columns={"yhat_base":"y_base"})
port_std_r = std.groupby(DATECOL, as_index=False)["yhat_reconc"].sum().rename(columns={"yhat_reconc":"y_std"})
port = port_std_b.merge(port_std_r, on=DATECOL)
port["delta_std"] = port["y_std"] - port["y_base"]
print("\n Delta mensual a nivel portfolio (STD — reconc - base):")
print(port[[DATECOL, "y_base", "y_std", "delta_std"]])
if active_exists:
port_act_r = act.groupby(DATECOL, as_index=False)["yhat_reconc"].sum().rename(columns={"yhat_reconc":"y_active"})
port = port.merge(port_act_r, on=DATECOL, how="left")
port["delta_active"] = port["y_active"] - port["y_base"]
print("\n Delta mensual a nivel portfolio (ACTIVE — reconc - base):")
print(port[[DATECOL, "y_base", "y_active", "delta_active"]])
# 4) Salud numérica: ¿hay negativos tras clip? (no debería)
neg_std = int((std["yhat_reconc"] < 0).sum())
print(f"\n Valores negativos en yhat_reconc (STD): {neg_std} (esperado 0)")
if active_exists:
neg_act = int((act["yhat_reconc"] < 0).sum())
print(f" Valores negativos en yhat_reconc (ACTIVE): {neg_act} (esperado 0)")
# 5) (Opcional) Diagnóstico “antes del clip” si guardaste los BEFORE
if os.path.exists(PATH_STD_BEFORE):
std_bef = pd.read_csv(PATH_STD_BEFORE, sep=CSV_SEP, parse_dates=[DATECOL]).rename(columns={"yhat_reconc":"yhat_before"})
chk = std.merge(std_bef[[*PAIR_COLS, DATECOL, "yhat_before"]], on=[*PAIR_COLS, DATECOL], how="left")
was_neg = int((chk["yhat_before"] < 0).sum())
trunc_to0 = int(((chk["yhat_before"] < 0) & (chk["yhat_reconc"] == 0)).sum())
print(f"\n STD — Negativos ANTES del clip: {was_neg} | Truncados a 0: {trunc_to0}")
if active_exists and os.path.exists(PATH_ACT_BEFORE):
act_bef = pd.read_csv(PATH_ACT_BEFORE, sep=CSV_SEP, parse_dates=[DATECOL]).rename(columns={"yhat_reconc":"yhat_before"})
chk2 = act.merge(act_bef[[*PAIR_COLS, DATECOL, "yhat_before"]], on=[*PAIR_COLS, DATECOL], how="left")
was_neg2 = int((chk2["yhat_before"] < 0).sum())
trunc_to0_2 = int(((chk2["yhat_before"] < 0) & (chk2["yhat_reconc"] == 0)).sum())
print(f" ACTIVE — Negativos ANTES del clip: {was_neg2} | Truncados a 0: {trunc_to0_2}")
Dispersión de delta (FM×PAIS mensual) — VARIANTE ESTÁNDAR: count 588.000000 mean 16947.409186 std 44442.971264 min -37718.528090 25% 0.346510 50% 49.759538 75% 3652.596253 max 213146.042284 Name: delta, dtype: float64 Dispersión de delta (FM×PAIS mensual) — VARIANTE SOLO ACTIVOS: count 588.000000 mean 8102.947618 std 25430.284757 min -7013.019893 25% 0.000000 50% 16.183798 75% 1238.507493 max 159552.082655 Name: delta, dtype: float64 Top 10 gaps relativos FM×PAIS (anual) — vs ANCLA real (VARIANTE ESTÁNDAR): FM_COST_TYPE PAIS y_reco_annual_std \ 6 Licencias MÉXICO 8.547584e+05 5 Licencias ESPAÑA 7.265202e+05 37 Servicios Extra ESPAÑA 1.282928e+06 10 Mtto. Contratos ESPAÑA 2.391161e+06 12 Mtto. Contratos PANAMÁ 4.588692e+05 0 Eficiencia Energética ESPAÑA 2.221276e+05 8 Mtto. Contratos COLOMBIA 1.167514e+04 24 Obras ESPAÑA 8.321506e+05 30 Servicios Ctto. ESPAÑA 4.524549e+06 21 Mtto. Correctivo REPÚBLICA DOMINICANA 2.960966e+05 target_annual gap_std_rel_% 6 1.505561e+04 5577.340300 5 8.879993e+04 718.154012 37 2.455461e+05 422.479645 10 5.724127e+05 317.733778 12 1.201991e+05 281.757487 0 7.648618e+04 190.415298 8 4.153047e+03 181.122264 24 3.049112e+05 172.915760 30 2.652928e+06 70.549248 21 2.105931e+05 40.601313 Top 10 gaps relativos FM×PAIS (anual) — vs ANCLA real (VARIANTE SOLO ACTIVOS): FM_COST_TYPE PAIS y_reco_annual_active \ 12 Mtto. Contratos PANAMÁ 5.773527e+05 10 Mtto. Contratos ESPAÑA 2.301954e+06 37 Servicios Extra ESPAÑA 9.638202e+05 6 Licencias MÉXICO 3.850800e+04 8 Mtto. Contratos COLOMBIA 9.922387e+03 22 Obras COLOMBIA 4.203225e+04 21 Mtto. Correctivo REPÚBLICA DOMINICANA 2.952839e+05 30 Servicios Ctto. ESPAÑA 3.538369e+06 14 Mtto. Contratos REPÚBLICA DOMINICANA 9.488495e+03 2 Eficiencia Energética PANAMÁ 1.343189e+05 target_annual gap_active_rel_% 12 1.201991e+05 380.330097 10 5.724127e+05 302.149273 37 2.455461e+05 292.521063 6 1.505561e+04 155.771688 8 4.153047e+03 138.918219 22 1.896421e+04 121.639886 21 2.105931e+05 40.215379 30 2.652928e+06 33.375992 14 7.218467e+03 31.447505 2 1.083357e+05 23.983943 Delta mensual a nivel portfolio (STD — reconc - base): FECHA y_base y_std delta_std 0 2024-01-01 3.026522e+06 3.862409e+06 835887.313741 1 2024-02-01 2.960127e+06 3.770082e+06 809955.190499 2 2024-03-01 3.008443e+06 3.825553e+06 817110.184307 3 2024-04-01 3.016421e+06 3.832718e+06 816297.027888 4 2024-05-01 3.049515e+06 3.875655e+06 826140.266870 5 2024-06-01 3.092858e+06 3.921584e+06 828726.012685 6 2024-07-01 3.012608e+06 3.826037e+06 813429.020084 7 2024-08-01 2.990727e+06 3.780875e+06 790147.478770 8 2024-09-01 3.187771e+06 4.045665e+06 857893.927492 9 2024-10-01 3.314882e+06 4.188240e+06 873357.882809 10 2024-11-01 3.306467e+06 4.177553e+06 871086.541225 11 2024-12-01 3.228334e+06 4.053380e+06 825045.754716 Delta mensual a nivel portfolio (ACTIVE — reconc - base): FECHA y_base y_active delta_active 0 2024-01-01 3.026522e+06 3.429791e+06 403269.143230 1 2024-02-01 2.960127e+06 3.347408e+06 387280.831051 2 2024-03-01 3.008443e+06 3.398769e+06 390326.252737 3 2024-04-01 3.016421e+06 3.413974e+06 397552.871186 4 2024-05-01 3.049515e+06 3.495270e+06 445754.815138 5 2024-06-01 3.092858e+06 3.473227e+06 380368.788921 6 2024-07-01 3.012608e+06 3.392821e+06 380213.515045 7 2024-08-01 2.990727e+06 3.335106e+06 344378.276031 8 2024-09-01 3.187771e+06 3.592323e+06 404552.192622 9 2024-10-01 3.314882e+06 3.775488e+06 460605.349142 10 2024-11-01 3.306467e+06 3.709828e+06 403361.697098 11 2024-12-01 3.228334e+06 3.595203e+06 366869.467199 Valores negativos en yhat_reconc (STD): 0 (esperado 0) Valores negativos en yhat_reconc (ACTIVE): 0 (esperado 0) STD — Negativos ANTES del clip: 2684 | Truncados a 0: 2684 ACTIVE — Negativos ANTES del clip: 928 | Truncados a 0: 928
Interpretación¶
Dispersión de deltas STD vs ACTIVE: En la variante estándar los ajustes mensuales por FM x PAIS son más grandes y dispersos (media ~16k, máximo >200k). En la variante solo activos los ajustes son menores (media ~8k, máximo ~160k). Esto confirma que al congelar los ceros reducimos la intensidad de los cambios.
Gaps relativos anuales: En STD vemos desviaciones muy altas respecto a las anclas, con gaps que superan el 5000% en algunos casos. En ACTIVE también hay desviaciones, pero son bastante más contenidas, en torno al 300 -400% en los peores casos. Congelar los ceros hace que el modelo se acerque más a lo que sería razonable.
Portfolio mensual: STD añade siempre unos 800 - 870k al mes sobre la base, de forma muy consistente. ACTIVE añade bastante menos, en torno a 350 - 450k. La diferencia es clara: STD infla mucho más el total.
Negativos: Después del clip no quedan negativos en ninguno de los dos enfoques. Pero antes del clip sí había: más de 2600 en STD y menos de 1000 en ACTIVE. Esto refuerza la idea de que ACTIVE es más estable y menos agresivo.
En resumen: la variante solo activos suaviza los ajustes, evita inflaciones excesivas y genera menos negativos antes del clip. Parece más fiel a la lógica de no tocar lo que históricamente es cero.
Cuantificación de pares base 0.¶
Para poder dar explicación de donde activamos y donde no activamos, vamos a identificar cuantos pares base=0 nos vienen incialmente y donde:
# Ruta del fichero base
PATH_PREDS = os.path.join(RUTA_RESULTADOS, "preds_por_serie_2024.csv")
# Cargamos
df_base = pd.read_csv(PATH_PREDS, sep=CSV_SEP, parse_dates=[DATECOL])
ZERO_THR = 1e-9 # mismo umbral que en ACTIVE
# Total de filas
total = len(df_base)
# Filas con base ≈ 0
zeros = (df_base["yhat_combo"].abs() <= ZERO_THR).sum()
# Porcentaje
pct = 100.0 * zeros / total
print(f"[Info] Total filas: {total}")
print(f"[Info] Filas con base=0 (≈0 con umbral {ZERO_THR}): {zeros} ({pct:.2f}%)")
# --- Por FM_COST_TYPE ---
g_fm = (df_base.assign(is_zero=(df_base["yhat_combo"].abs() <= ZERO_THR))
.groupby("FM_COST_TYPE")["is_zero"]
.agg(["sum","count"])
.reset_index())
g_fm["pct_zero_%"] = 100.0 * g_fm["sum"] / g_fm["count"]
print("\n[Info] % de pares base=0 por FM_COST_TYPE")
print(g_fm.sort_values("pct_zero_%", ascending=False))
# --- Por PAIS ---
# Necesitamos unir con dim_buildings para conocer el país
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP)
df_with_pais = df_base.merge(dim[["ID_BUILDING","PAIS"]], on="ID_BUILDING", how="left")
g_pais = (df_with_pais.assign(is_zero=(df_with_pais["yhat_combo"].abs() <= ZERO_THR))
.groupby("PAIS")["is_zero"]
.agg(["sum","count"])
.reset_index())
g_pais["pct_zero_%"] = 100.0 * g_pais["sum"] / g_pais["count"]
print("\n[Info] % de pares base=0 por PAIS")
print(g_pais.sort_values("pct_zero_%", ascending=False))
[Info] Total filas: 29148 [Info] Filas con base=0 (≈0 con umbral 1e-09): 7877 (27.02%) [Info] % de pares base=0 por FM_COST_TYPE FM_COST_TYPE sum count pct_zero_% 1 Licencias 2552 3096 82.428941 4 Obras 695 1080 64.351852 0 Eficiencia Energética 381 840 45.357143 6 Servicios Extra 1808 4512 40.070922 2 Mtto. Contratos 1010 4380 23.059361 3 Mtto. Correctivo 764 5364 14.243102 5 Servicios Ctto. 348 4056 8.579882 7 Suministros 319 5820 5.481100 [Info] % de pares base=0 por PAIS PAIS sum count pct_zero_% 2 ESPAÑA 6697 18924 35.388924 5 PERÚ 371 1392 26.652299 3 MÉXICO 412 2304 17.881944 1 COSTA RICA 41 588 6.972789 4 PANAMÁ 181 2736 6.615497 6 REPÚBLICA DOMINICANA 18 300 6.000000 0 COLOMBIA 157 2904 5.406336
Interpretación¶
En el dataset inicial, casi un 27% de las filas ya son ceros estructurales. Y además están muy concentrados en ciertos tipos de coste: Licencias (82%), Obras (64%), Eficiencia Energética (45%) y Servicios Extra (40%).
También por país: el grueso está en España (35% de sus filas) y, en menor medida, en Perú (27%). En el resto los ceros son bastante menos frecuentes.
Cuando hemos reconciliado:
En la variante STD, esos ceros entran en el ajuste. Eso genera tensiones en el sistema y provoca que antes del clip aparezcan más de 2600 negativos, que luego tenemos que truncar a cero.
En la variante ACTIVE, al congelar los ceros, esos pares ya no se mueven. Como consecuencia, los negativos antes del clip bajan a menos de 1000. Es decir, nos ahorramos buena parte de esos problemas simplemente por no tocar lo que ya estaba en cero estructural.
Conclusion: Los ceros estructurales son muy abundantes y están concentrados en algunos costes y países. Y son precisamente esos los que más ruido generan en la reconciliación STD. La variante ACTIVE se adapta mejor a esta realidad porque respeta esos ceros y reduce los ajustes forzados.
Comparación de la evolución de los reconciliadores empleados en paso 10, 11 y 12.¶
Ahora la idea consiste en comparar cómo evoluciona la predicción base y cómo se ajusta en cada uno de los pasos de reconciliación (10, 11 y 12). Para ello, necesitamos cargar los tres ficheros de salida de reconciliación y alinearlos sobre las mismas llaves:
Paso 10 -> RESULTADOS/preds_reconciliadas_2024.csv
Paso 11 -> RESULTADOS/preds_reconciliadas_2024_prod.csv
Paso 12 -> RESULTADOS/preds_reconciliadas_step12.csv
Cada fichero tiene las columnas ID_BUILDING
, FM_COST_TYPE
, FECHA
, yhat_base
y yhat_reconc
, así que vamos a hacer merge sin problemas.
Finalmente vamos a realizar un resumen:
A nivel portfolio mensual (con suma de todos los pares).
A nivel
FM_COST_TYPE
xPAIS
, para ver la granularidad.Dispersión de las diferencias en bottom (mínimo, máximo, percentiles).
De esta manera conseguimos ver:
Cuánto mueve cada reconciliación respecto a la base.
Si las tres producen un mismo patrón (por ejemplo, todas hacia arriba, pero en magnitudes distintas).
Si el Paso 12, al usar IPC real, se diferencia claramente del 10 y el 11.
# ============================================================
# Comparativa base vs reconciliados: Paso 10, 11 y 12 (STD/ACTIVE)
# Lee:
# RESULTADOS/preds_reconciliadas_2024.csv (Paso 10)
# RESULTADOS/preds_reconciliadas_2024_prod.csv (Paso 11)
# RESULTADOS/preds_reconciliadas_step12.csv (Paso 12 STD)
# RESULTADOS/preds_reconciliadas_step12_activeonly.csv (Paso 12 ACTIVE)
# Une por (ID_BUILDING, FM_COST_TYPE, FECHA)
# Calcula deltas contra base y agrega:
# * Portfolio mensual
# * FM_COST_TYPE × PAIS mensual
# * Dispersión bottom
# Exporta CSVs comparativos en REPORTING/
# ============================================================
# ----- Rutas y constantes
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
os.makedirs(RUTA_REPORTING, exist_ok=True)
PATH_P10 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024.csv")
PATH_P11 = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_2024_prod.csv")
PATH_P12_STD = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv")
PATH_P12_ACT = os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly.csv")
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
PAIR_COLS = ["ID_BUILDING", "FM_COST_TYPE"]
DATECOL = "FECHA"
# ----- Helper de lectura
def _read_preds(path, tag):
if not os.path.exists(path):
print(f"[AVISO] Falta archivo de {tag}: {path}")
return pd.DataFrame(columns=[*PAIR_COLS, DATECOL, f"yhat_base_{tag}", f"yhat_reconc_{tag}"])
df = pd.read_csv(path, sep=CSV_SEP, parse_dates=[DATECOL])
need = [*PAIR_COLS, DATECOL, "yhat_base", "yhat_reconc"]
miss = [c for c in need if c not in df.columns]
if miss:
raise KeyError(f"[{tag}] faltan columnas {miss} en {os.path.basename(path)}")
df = df[need].rename(columns={
"yhat_base": f"yhat_base_{tag}",
"yhat_reconc": f"yhat_reconc_{tag}"
})
return df
# ----- Carga
p10 = _read_preds(PATH_P10, "p10")
p11 = _read_preds(PATH_P11, "p11")
p12std = _read_preds(PATH_P12_STD, "p12std")
p12act = _read_preds(PATH_P12_ACT, "p12act")
if all(len(d)==0 for d in [p10, p11, p12std, p12act]):
raise FileNotFoundError("No se encontró ningún output de reconciliación (p10/p11/p12std/p12act).")
from functools import reduce
dfs = [d for d in [p10, p11, p12std, p12act] if len(d) > 0]
df_cmp = reduce(lambda a,b: pd.merge(a, b, on=PAIR_COLS+[DATECOL], how="inner"), dfs)
if df_cmp.empty:
raise ValueError("La intersección entre p10/p11/p12std/p12act es vacía. Revisa que compartan las mismas llaves y meses.")
# ----- Verificación suave de la base
base_cols = [c for c in df_cmp.columns if c.startswith("yhat_base_")]
if base_cols:
base_stack = df_cmp[base_cols].astype(float)
drift = float((base_stack.max(axis=1) - base_stack.min(axis=1)).abs().max())
print(f"[Check] Discrepancia máxima entre 'yhat_base_*' = {drift:,.6f}")
base_ref_col = "yhat_base_p10" if "yhat_base_p10" in base_cols else base_cols[0]
else:
raise KeyError("No se encontró ninguna columna de base en los archivos de entrada.")
df_cmp["yhat_base_ref"] = df_cmp[base_ref_col].astype(float)
# ----- Deltas vs base
for tag in ["p10","p11","p12std","p12act"]:
yrec = f"yhat_reconc_{tag}"
if yrec in df_cmp.columns:
df_cmp[f"delta_{tag}"] = df_cmp[yrec].astype(float) - df_cmp["yhat_base_ref"]
# ----- Exporte bottom comparativo
BOTTOM_OUT = os.path.join(RUTA_REPORTING, "compare_recon_steps_bottom.csv")
keep_bottom_cols = [*PAIR_COLS, DATECOL, "yhat_base_ref"] + \
[c for c in [
"yhat_reconc_p10","yhat_reconc_p11","yhat_reconc_p12std","yhat_reconc_p12act",
"delta_p10","delta_p11","delta_p12std","delta_p12act"
] if c in df_cmp.columns]
df_cmp[keep_bottom_cols].sort_values(PAIR_COLS+[DATECOL]).to_csv(BOTTOM_OUT, sep=CSV_SEP, index=False)
print(f"[OK] Guardado bottom comparativo: {BOTTOM_OUT} ({len(df_cmp)} filas)")
# ----- Portfolio mensual
agg_parts = {
"y_base": ("yhat_base_ref", "sum")
}
if "yhat_reconc_p10" in df_cmp.columns: agg_parts["y_p10"] = ("yhat_reconc_p10", "sum")
if "yhat_reconc_p11" in df_cmp.columns: agg_parts["y_p11"] = ("yhat_reconc_p11", "sum")
if "yhat_reconc_p12std" in df_cmp.columns: agg_parts["y_p12std"] = ("yhat_reconc_p12std","sum")
if "yhat_reconc_p12act" in df_cmp.columns: agg_parts["y_p12act"] = ("yhat_reconc_p12act","sum")
agg = df_cmp.groupby(DATECOL).agg(**agg_parts).reset_index()
for col in ["y_p10","y_p11","y_p12std","y_p12act"]:
if col in agg.columns:
agg[f"delta_{col.split('_')[-1]}"] = agg[col] - agg["y_base"]
PORT_OUT = os.path.join(RUTA_REPORTING, "compare_recon_steps_portfolio.csv")
agg.sort_values(DATECOL).to_csv(PORT_OUT, sep=CSV_SEP, index=False)
print(f"[OK] Guardado portfolio mensual: {PORT_OUT}")
# ----- FM_COST_TYPE × PAIS mensual
SEG_OUT = os.path.join(RUTA_REPORTING, "compare_recon_steps_fmcost_pais.csv")
if os.path.exists(PATH_DIM):
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP)
# Asegura columna PAIS
if "PAIS" not in dim.columns:
up = {c.upper(): c for c in dim.columns}
if "COUNTRY" in up: dim = dim.rename(columns={up["COUNTRY"]:"PAIS"})
if "COUNTRY_DEF" in up: dim = dim.rename(columns={up["COUNTRY_DEF"]:"PAIS"})
if "PAIS" in dim.columns:
df_seg = df_cmp.merge(dim[["ID_BUILDING","PAIS"]], on="ID_BUILDING", how="left")
grp_cols = ["FM_COST_TYPE","PAIS", DATECOL]
seg_parts = {
"y_base": ("yhat_base_ref", "sum")
}
if "yhat_reconc_p10" in df_seg.columns: seg_parts["y_p10"] = ("yhat_reconc_p10", "sum")
if "yhat_reconc_p11" in df_seg.columns: seg_parts["y_p11"] = ("yhat_reconc_p11", "sum")
if "yhat_reconc_p12std" in df_seg.columns: seg_parts["y_p12std"] = ("yhat_reconc_p12std", "sum")
if "yhat_reconc_p12act" in df_seg.columns: seg_parts["y_p12act"] = ("yhat_reconc_p12act", "sum")
seg = df_seg.groupby(grp_cols).agg(**seg_parts).reset_index()
for col in ["y_p10","y_p11","y_p12std","y_p12act"]:
if col in seg.columns:
seg[f"delta_{col.split('_')[-1]}"] = seg[col] - seg["y_base"]
seg.sort_values(grp_cols).to_csv(SEG_OUT, sep=CSV_SEP, index=False)
print(f"[OK] Guardado FM_COST_TYPE×PAIS mensual: {SEG_OUT}")
else:
print("[AVISO] dim_buildings.csv no tiene columna PAIS. Se omite la vista FM_COST_TYPE×PAIS.")
else:
print("[AVISO] No se encontró dim_buildings.csv. Se omite la vista FM_COST_TYPE×PAIS.")
# ----- Dispersión de deltas en bottom
stats_cols = [c for c in ["delta_p10","delta_p11","delta_p12std","delta_p12act"] if c in df_cmp.columns]
if stats_cols:
print("\n[Resumen] Dispersión de deltas (reconc - base) en bottom:")
for c in stats_cols:
desc = df_cmp[c].describe(percentiles=[.01,.1,.25,.5,.75,.9,.99])
print(f"\n{c}:\n{desc}")
# ----- Vista rápida en consola
print("\n[Muestra portfolio mensual]")
print(agg.sort_values(DATECOL).head(12))
print("\n[Muestra bottom comparativo]")
print(df_cmp[keep_bottom_cols].sort_values(PAIR_COLS+[DATECOL]).head(10))
[Check] Discrepancia máxima entre 'yhat_base_*' = 0.000000 [OK] Guardado bottom comparativo: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_steps_bottom.csv (29148 filas) [OK] Guardado portfolio mensual: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_steps_portfolio.csv [OK] Guardado FM_COST_TYPE×PAIS mensual: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_steps_fmcost_pais.csv [Resumen] Dispersión de deltas (reconc - base) en bottom: delta_p10: count 29148.000000 mean 109.232172 std 2373.323362 min -24584.321052 1% -989.374110 10% -4.352855 25% 0.000000 50% 0.304418 75% 5.653931 90% 50.166252 99% 2283.953159 max 171658.137550 Name: delta_p10, dtype: float64 delta_p11: count 29148.0 mean 0.0 std 0.0 min 0.0 1% 0.0 10% 0.0 25% 0.0 50% 0.0 75% 0.0 90% 0.0 99% 0.0 max 0.0 Name: delta_p11, dtype: float64 delta_p12std: count 29148.000000 mean 341.878571 std 5197.783342 min -16859.044681 1% -719.412721 10% -38.053878 25% -0.166144 50% 0.478442 75% 20.571566 90% 136.489526 99% 5141.801808 max 215342.684158 Name: delta_p12std, dtype: float64 delta_p12act: count 29148.000000 mean 163.460038 std 2237.847041 min -16859.044681 1% -550.606555 10% -9.741310 25% 0.000000 50% 0.000525 75% 3.324407 90% 59.445819 99% 3207.840710 max 84055.835845 Name: delta_p12act, dtype: float64 [Muestra portfolio mensual] FECHA y_base y_p10 y_p11 y_p12std \ 0 2024-01-01 3.026522e+06 3.250155e+06 3.026522e+06 3.862409e+06 1 2024-02-01 2.960127e+06 3.082621e+06 2.960127e+06 3.770082e+06 2 2024-03-01 3.008443e+06 3.049105e+06 3.008443e+06 3.825553e+06 3 2024-04-01 3.016421e+06 3.176957e+06 3.016421e+06 3.832718e+06 4 2024-05-01 3.049515e+06 2.989022e+06 3.049515e+06 3.875655e+06 5 2024-06-01 3.092858e+06 2.977756e+06 3.092858e+06 3.921584e+06 6 2024-07-01 3.012608e+06 2.931273e+06 3.012608e+06 3.826037e+06 7 2024-08-01 2.990727e+06 3.086346e+06 2.990727e+06 3.780875e+06 8 2024-09-01 3.187771e+06 4.557699e+06 3.187771e+06 4.045665e+06 9 2024-10-01 3.314882e+06 4.807961e+06 3.314882e+06 4.188240e+06 10 2024-11-01 3.306467e+06 3.322896e+06 3.306467e+06 4.177553e+06 11 2024-12-01 3.228334e+06 3.146782e+06 3.228334e+06 4.053380e+06 y_p12act delta_p10 delta_p11 delta_p12std delta_p12act 0 3.429791e+06 2.236336e+05 0.0 835887.313741 403269.143230 1 3.347408e+06 1.224942e+05 0.0 809955.190499 387280.831051 2 3.398769e+06 4.066254e+04 0.0 817110.184307 390326.252737 3 3.413974e+06 1.605364e+05 0.0 816297.027888 397552.871186 4 3.495270e+06 -6.049325e+04 0.0 826140.266870 445754.815138 5 3.473227e+06 -1.151019e+05 0.0 828726.012685 380368.788921 6 3.392821e+06 -8.133424e+04 0.0 813429.020084 380213.515045 7 3.335106e+06 9.561901e+04 0.0 790147.478770 344378.276031 8 3.592323e+06 1.369928e+06 0.0 857893.927492 404552.192622 9 3.775488e+06 1.493078e+06 0.0 873357.882809 460605.349142 10 3.709828e+06 1.642908e+04 0.0 871086.541225 403361.697098 11 3.595203e+06 -8.155242e+04 0.0 825045.754716 366869.467199 [Muestra bottom comparativo] ID_BUILDING FM_COST_TYPE FECHA yhat_base_ref \ 0 2 Eficiencia Energética 2024-01-01 0.0 1 2 Eficiencia Energética 2024-02-01 0.0 2 2 Eficiencia Energética 2024-03-01 0.0 3 2 Eficiencia Energética 2024-04-01 0.0 4 2 Eficiencia Energética 2024-05-01 0.0 5 2 Eficiencia Energética 2024-06-01 0.0 6 2 Eficiencia Energética 2024-07-01 0.0 7 2 Eficiencia Energética 2024-08-01 0.0 8 2 Eficiencia Energética 2024-09-01 0.0 9 2 Eficiencia Energética 2024-10-01 0.0 yhat_reconc_p10 yhat_reconc_p11 yhat_reconc_p12std yhat_reconc_p12act \ 0 0.000000e+00 0.0 7901.969069 0.0 1 0.000000e+00 0.0 7665.118813 0.0 2 0.000000e+00 0.0 7885.690175 0.0 3 0.000000e+00 0.0 7820.281633 0.0 4 1.342612e-09 0.0 7839.947659 0.0 5 0.000000e+00 0.0 7999.217869 0.0 6 3.243058e-10 0.0 7775.350421 0.0 7 0.000000e+00 0.0 7798.841951 0.0 8 0.000000e+00 0.0 8159.586637 0.0 9 0.000000e+00 0.0 8683.304007 0.0 delta_p10 delta_p11 delta_p12std delta_p12act 0 0.000000e+00 0.0 7901.969069 0.0 1 0.000000e+00 0.0 7665.118813 0.0 2 0.000000e+00 0.0 7885.690175 0.0 3 0.000000e+00 0.0 7820.281633 0.0 4 1.342612e-09 0.0 7839.947659 0.0 5 0.000000e+00 0.0 7999.217869 0.0 6 3.243058e-10 0.0 7775.350421 0.0 7 0.000000e+00 0.0 7798.841951 0.0 8 0.000000e+00 0.0 8159.586637 0.0 9 0.000000e+00 0.0 8683.304007 0.0
Interpretación¶
- Bases idénticas. La discrepancia máxima entre las distintas “base” es 0: estamos comparando lo mismo en los 3 pasos, consistencia perfecta.
Paso 10 (ref: real 2024 y W: con MASE12). Los deltas en bottom son pequeños y centrados en cero: el P10 ajusta suave y casi siempre poco.
Paso 11 (W: identidad) . No mueve nada (todos los deltas 0): sirve de referencia de “sin cambio” en esta comparativa.
Paso 12 (ref: real 2023 + IPC x pais y W: con MASE12)
Estándar (STD). Mueve bastante más: media del delta ~342 y una cola larga (máx ~215k). Es coherente con aplicar anclas fuertes (real 2023×IPC) repartidas por shares.
Solo activos (ACTIVE). Mueve menos que STD: media ~163 y dispersión más contenida; al congelar los pares con base≈0 reducimos el “arrastre” y el ruido.
- Portfolio mensual.
Con STD subimos ~0.8-0.87M cada mes vs base, de forma bastante estable.
Con ACTIVE subimos ~0.34-0.46M/mes: el efecto existe, pero es mucho más moderado.
Ejemplo bottom mostrado. En un par con base=0, STD lo eleva (p.ej. ~7.9k-8.7k según el mes), mientras que ACTIVE lo mantiene en 0. Justo el comportamiento que buscábamos.
No hay negativos tras el clip en ninguna variante: bien.
Añadimos una visión gráfica de los resultados:
# ============================================================
# Gráficos comparativos Paso 10, 11 y 12 (STD y ACTIVE)
# ============================================================
FIG_DIR = os.path.join(RUTA_REPORTING, "figs_compare_recon")
os.makedirs(FIG_DIR, exist_ok=True)
# --- 1) Serie temporal portfolio ---
plt.figure(figsize=(12,6))
plt.plot(agg[DATECOL], agg["y_base"], label="Base", color="black", linewidth=2)
if "y_p10" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p10"], label="Reconc P10", linestyle="--")
if "y_p11" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p11"], label="Reconc P11", linestyle="--")
# Paso 12: soporta columna única 'y_p12' o las dos variantes 'y_p12std' y 'y_p12act'
if "y_p12std" in agg.columns or "y_p12act" in agg.columns:
if "y_p12std" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p12std"], label="Reconc P12 STD", linestyle="--")
if "y_p12act" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p12act"], label="Reconc P12 ACTIVE", linestyle="-.")
elif "y_p12" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p12"], label="Reconc P12", linestyle="--")
plt.title("Serie temporal portfolio — Base vs Reconciliados")
plt.xlabel("Mes")
plt.ylabel("Suma mensual")
plt.legend()
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "portfolio_series.png"))
plt.close()
# --- 2) Boxplots de deltas en bottom ---
plt.figure(figsize=(9,6))
deltas_data, labels = [], []
for c, lab in [
("delta_p10", "Paso 10"),
("delta_p11", "Paso 11"),
("delta_p12", "Paso 12"),
("delta_p12std", "Paso 12 STD"),
("delta_p12act", "Paso 12 ACTIVE"),
]:
if c in df_cmp.columns:
deltas_data.append(df_cmp[c].values)
labels.append(lab)
if deltas_data:
plt.boxplot(deltas_data, labels=labels, showfliers=False)
plt.axhline(0, color="grey", linestyle=":")
plt.title("Distribución de deltas bottom (reconc - base)")
plt.ylabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "bottom_deltas_boxplot.png"))
plt.close()
else:
print("[AVISO] No hay columnas de delta para boxplot.")
# --- 3) Heatmaps anuales FM_COST_TYPE×PAIS (opcional) ---
# Genera hasta dos heatmaps: P12 STD y P12 ACTIVE (si existen).
if os.path.exists(PATH_DIM):
dim = pd.read_csv(PATH_DIM, sep=CSV_SEP) if 'dim' not in globals() else dim
if os.path.exists(PATH_DIM) and "PAIS" in dim.columns:
# Construimos dataframe base para agregados
needed = ["ID_BUILDING","FM_COST_TYPE", DATECOL, "yhat_base_ref",
"yhat_reconc_p10","yhat_reconc_p11",
"yhat_reconc_p12","yhat_reconc_p12std","yhat_reconc_p12act"]
have = [c for c in needed if c in df_cmp.columns]
df_seg = df_cmp[have].merge(dim[["ID_BUILDING","PAIS"]], on="ID_BUILDING", how="left")
rename_map = {}
if "yhat_base_ref" in df_seg.columns: rename_map["yhat_base_ref"] = "y_base"
if "yhat_reconc_p10" in df_seg.columns: rename_map["yhat_reconc_p10"] = "y_p10"
if "yhat_reconc_p11" in df_seg.columns: rename_map["yhat_reconc_p11"] = "y_p11"
if "yhat_reconc_p12" in df_seg.columns: rename_map["yhat_reconc_p12"] = "y_p12"
if "yhat_reconc_p12std" in df_seg.columns: rename_map["yhat_reconc_p12std"] = "y_p12std"
if "yhat_reconc_p12act" in df_seg.columns: rename_map["yhat_reconc_p12act"] = "y_p12act"
df_seg = df_seg.rename(columns=rename_map)
df_seg["ANIO"] = pd.to_datetime(df_seg[DATECOL]).dt.year
agg_dict = {}
if "y_base" in df_seg.columns: agg_dict["base"] = ("y_base", "sum")
if "y_p12" in df_seg.columns: agg_dict["p12"] = ("y_p12", "sum")
if "y_p12std" in df_seg.columns: agg_dict["p12std"] = ("y_p12std", "sum")
if "y_p12act" in df_seg.columns: agg_dict["p12act"] = ("y_p12act", "sum")
if agg_dict:
seg_ann = (df_seg.groupby(["FM_COST_TYPE","PAIS","ANIO"])
.agg(**agg_dict).reset_index())
# Heatmap P12 STD
if {"base","p12std"}.issubset(seg_ann.columns):
seg_ann["gap_rel_p12std"] = 100.0 * (seg_ann["p12std"] - seg_ann["base"]) / seg_ann["base"].replace(0, np.nan)
pivot_std = seg_ann.pivot_table(index="FM_COST_TYPE", columns="PAIS", values="gap_rel_p12std", aggfunc="mean")
plt.figure(figsize=(14,6))
im = plt.imshow(pivot_std.fillna(0).values, cmap="RdBu", aspect="auto", vmin=-100, vmax=100)
plt.colorbar(im, label="% gap relativo Paso 12 STD vs base (anual)")
plt.xticks(range(len(pivot_std.columns)), pivot_std.columns, rotation=90)
plt.yticks(range(len(pivot_std.index)), pivot_std.index)
plt.title("Heatmap gap relativo Paso 12 STD vs base (anual)")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "heatmap_gap_p12std.png"))
plt.close()
# Heatmap P12 ACTIVE
if {"base","p12act"}.issubset(seg_ann.columns):
seg_ann["gap_rel_p12act"] = 100.0 * (seg_ann["p12act"] - seg_ann["base"]) / seg_ann["base"].replace(0, np.nan)
pivot_act = seg_ann.pivot_table(index="FM_COST_TYPE", columns="PAIS", values="gap_rel_p12act", aggfunc="mean")
plt.figure(figsize=(14,6))
im = plt.imshow(pivot_act.fillna(0).values, cmap="RdBu", aspect="auto", vmin=-100, vmax=100)
plt.colorbar(im, label="% gap relativo Paso 12 ACTIVE vs base (anual)")
plt.xticks(range(len(pivot_act.columns)), pivot_act.columns, rotation=90)
plt.yticks(range(len(pivot_act.index)), pivot_act.index)
plt.title("Heatmap gap relativo Paso 12 ACTIVE vs base (anual)")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "heatmap_gap_p12active.png"))
plt.close()
else:
print("[AVISO] No hay columnas suficientes para heatmaps (base/p12std/p12act).")
else:
print("[AVISO] No se encontró dim_buildings.csv o falta 'PAIS'; se omiten heatmaps.")
print(f"[OK] Gráficos guardados en {FIG_DIR}")
[OK] Gráficos guardados en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_compare_recon
# ============================================================
# Visualización comparativa: Base vs Reconciliados (P10, P11, P12 STD/ACTIVE)
# Requiere CSVs:
# - REPORTING/compare_recon_steps_portfolio.csv
# - REPORTING/compare_recon_steps_bottom.csv
# - REPORTING/compare_recon_steps_fmcost_pais.csv
# ============================================================
import os, numpy as np, pandas as pd
import matplotlib.pyplot as plt
# ---- Rutas base (ajusta si difiere) ----
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
CSV_SEP = ";"
DATECOL = "FECHA"
# ---- Archivos de entrada ----
PATH_PORT = os.path.join(RUTA_REPORTING, "compare_recon_steps_portfolio.csv")
PATH_BOTTOM = os.path.join(RUTA_REPORTING, "compare_recon_steps_bottom.csv")
PATH_FMPAIS = os.path.join(RUTA_REPORTING, "compare_recon_steps_fmcost_pais.csv")
# ---- Carpeta de figuras ----
FIG_DIR = os.path.join(RUTA_REPORTING, "figs_compare_recon")
os.makedirs(FIG_DIR, exist_ok=True)
# ------------------------------------------------------------------
# 1) Carga de datos (robusta a columnas opcionales y parseo de fechas)
# ------------------------------------------------------------------
def _read_csv_safe(path, parse_dates=None, sep=CSV_SEP):
if not os.path.exists(path):
raise FileNotFoundError(f"No se encontró {path}")
df = pd.read_csv(path, sep=sep, parse_dates=parse_dates)
df.columns = [str(c).strip() for c in df.columns]
return df
agg = _read_csv_safe(PATH_PORT, parse_dates=[DATECOL])
df_cmp = _read_csv_safe(PATH_BOTTOM, parse_dates=[DATECOL])
df_seg = _read_csv_safe(PATH_FMPAIS, parse_dates=[DATECOL])
# Detectamos columnas presentes
# Portfolio: soporta y_p12 (formato antiguo) o y_p12std/y_p12act (nuevo)
has_p12std = "y_p12std" in agg.columns
has_p12act = "y_p12act" in agg.columns
has_p12 = "y_p12" in agg.columns
# Bottom deltas
delta_cols_bottom = [c for c in ["delta_p10","delta_p11","delta_p12","delta_p12std","delta_p12act"] if c in df_cmp.columns]
# ============================================================
# 2) Serie temporal portfolio — Base vs Reconciliados
# ============================================================
plt.figure(figsize=(12,6))
if "y_base" in agg.columns:
plt.plot(agg[DATECOL], agg["y_base"], label="Base", color="black", linewidth=2)
if "y_p10" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p10"], label="Reconc P10", linestyle="--")
if "y_p11" in agg.columns:
plt.plot(agg[DATECOL], agg["y_p11"], label="Reconc P11", linestyle="--")
# P12 variantes
if has_p12std or has_p12act:
if has_p12std:
plt.plot(agg[DATECOL], agg["y_p12std"], label="Reconc P12 STD", linestyle="--")
if has_p12act:
plt.plot(agg[DATECOL], agg["y_p12act"], label="Reconc P12 ACTIVE", linestyle="-.")
elif has_p12:
plt.plot(agg[DATECOL], agg["y_p12"], label="Reconc P12", linestyle="--")
plt.title("Serie temporal portfolio — Base vs Reconciliados")
plt.xlabel("Mes")
plt.ylabel("Suma mensual")
plt.legend()
plt.tight_layout()
out1 = os.path.join(FIG_DIR, "portfolio_series.png")
plt.savefig(out1, dpi=150)
plt.show()
# ============================================================
# 3) Boxplots de deltas en bottom (reconc - base)
# ============================================================
if len(delta_cols_bottom) > 0:
plt.figure(figsize=(9,6))
data = [df_cmp[c].values for c in delta_cols_bottom]
labels = []
for c in delta_cols_bottom:
if c == "delta_p12std": labels.append("Paso 12 STD")
elif c == "delta_p12act": labels.append("Paso 12 ACTIVE")
else: labels.append(c.replace("delta_","Paso "))
plt.boxplot(data, labels=labels, showfliers=False)
plt.axhline(0, color="grey", linestyle=":", linewidth=1)
plt.title("Distribución de deltas bottom (reconc - base)")
plt.ylabel("Delta")
plt.tight_layout()
out2 = os.path.join(FIG_DIR, "bottom_deltas_boxplot.png")
plt.savefig(out2, dpi=150)
plt.show()
else:
print("[AVISO] No se encontraron columnas de delta en bottom para boxplot.")
# ============================================================
# 4) Heatmaps anuales FM_COST_TYPE×PAIS — P12 STD y P12 ACTIVE
# ============================================================
need_cols = {"FM_COST_TYPE","PAIS",DATECOL,"y_base"}
if not need_cols.issubset(df_seg.columns):
print("[AVISO] El CSV FM_COST_TYPE×PAIS no tiene columnas mínimas para heatmaps.")
else:
seg = df_seg.copy()
seg["ANIO"] = pd.to_datetime(seg[DATECOL]).dt.year
# Mapear nombres si vienen como yhat_reconc_*
rename_map = {}
if "yhat_reconc_p12std" in seg.columns: rename_map["yhat_reconc_p12std"] = "y_p12std"
if "yhat_reconc_p12act" in seg.columns: rename_map["yhat_reconc_p12act"] = "y_p12act"
if rename_map:
seg = seg.rename(columns=rename_map)
agg_dict = {"y_base": "sum"}
if "y_p12std" in seg.columns: agg_dict["y_p12std"] = "sum"
if "y_p12act" in seg.columns: agg_dict["y_p12act"] = "sum"
if "y_p12" in seg.columns: agg_dict["y_p12"] = "sum" # por compatibilidad
seg_ann = seg.groupby(["FM_COST_TYPE","PAIS","ANIO"], as_index=False).agg(agg_dict)
last_year = int(seg_ann["ANIO"].max())
# Heatmap P12 STD
if {"y_base","y_p12std"}.issubset(seg_ann.columns):
seg_std = seg_ann[seg_ann["ANIO"]==last_year].copy()
seg_std["gap_rel_p12std"] = 100.0 * (seg_std["y_p12std"] - seg_std["y_base"]) / seg_std["y_base"].replace(0, np.nan)
pivot_std = seg_std.pivot_table(index="FM_COST_TYPE", columns="PAIS", values="gap_rel_p12std", aggfunc="mean")
plt.figure(figsize=(14,6))
im = plt.imshow(pivot_std.fillna(0).values, cmap="RdBu", aspect="auto", vmin=-100, vmax=100)
plt.colorbar(im, label=f"% gap relativo P12 STD vs base (anual {last_year})")
plt.xticks(range(len(pivot_std.columns)), pivot_std.columns, rotation=90)
plt.yticks(range(len(pivot_std.index)), pivot_std.index)
plt.title(f"Heatmap gap relativo P12 STD vs base (anual {last_year})")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "heatmap_gap_p12std.png"), dpi=150)
plt.show()
# Heatmap P12 ACTIVE
if {"y_base","y_p12act"}.issubset(seg_ann.columns):
seg_act = seg_ann[seg_ann["ANIO"]==last_year].copy()
seg_act["gap_rel_p12act"] = 100.0 * (seg_act["y_p12act"] - seg_act["y_base"]) / seg_act["y_base"].replace(0, np.nan)
pivot_act = seg_act.pivot_table(index="FM_COST_TYPE", columns="PAIS", values="gap_rel_p12act", aggfunc="mean")
plt.figure(figsize=(14,6))
im = plt.imshow(pivot_act.fillna(0).values, cmap="RdBu", aspect="auto", vmin=-100, vmax=100)
plt.colorbar(im, label=f"% gap relativo P12 ACTIVE vs base (anual {last_year})")
plt.xticks(range(len(pivot_act.columns)), pivot_act.columns, rotation=90)
plt.yticks(range(len(pivot_act.index)), pivot_act.index)
plt.title(f"Heatmap gap relativo P12 ACTIVE vs base (anual {last_year})")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "heatmap_gap_p12active.png"), dpi=150)
plt.show()
# ============================================================
# 5) Extras: scatter y barras de delta (P12 STD/ACTIVE)
# ============================================================
# Scatter Base vs Reconc por mes
plt.figure(figsize=(6,6))
plotted_any = False
if "y_p10" in agg.columns:
plt.scatter(agg["y_base"], agg["y_p10"], alpha=0.7, label="P10", marker="o"); plotted_any = True
if "y_p11" in agg.columns:
plt.scatter(agg["y_base"], agg["y_p11"], alpha=0.7, label="P11", marker="^"); plotted_any = True
if has_p12std:
plt.scatter(agg["y_base"], agg["y_p12std"], alpha=0.7, label="P12 STD", marker="s"); plotted_any = True
if has_p12act:
plt.scatter(agg["y_base"], agg["y_p12act"], alpha=0.7, label="P12 ACTIVE", marker="x"); plotted_any = True
elif has_p12:
plt.scatter(agg["y_base"], agg["y_p12"], alpha=0.7, label="P12", marker="s"); plotted_any = True
if plotted_any:
mins = [agg["y_base"].min()]
maxs = [agg["y_base"].max()]
for c in agg.columns:
if c.startswith("y_p"):
mins.append(agg[c].min()); maxs.append(agg[c].max())
minv, maxv = float(np.nanmin(mins)), float(np.nanmax(maxs))
plt.plot([minv, maxv], [minv, maxv], color="grey", linestyle=":", linewidth=1)
plt.xlabel("Portfolio mensual — Base")
plt.ylabel("Portfolio mensual — Reconc")
plt.title("Scatter Base vs Reconc (por mes)")
plt.legend()
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "scatter_portfolio_base_vs_recon.png"), dpi=150)
plt.show()
else:
plt.close()
# Barras de delta mensuales (si existen)
if has_p12std or has_p12act or has_p12:
xlab = agg[DATECOL].dt.strftime("%Y-%m")
plt.figure(figsize=(12,4))
width = 0.35
x = np.arange(len(xlab))
plotted_any = False
if has_p12std:
plt.bar(x - width/2, (agg["y_p12std"] - agg["y_base"]).values, width=width, label="P12 STD - Base"); plotted_any = True
if has_p12act:
plt.bar(x + width/2, (agg["y_p12act"] - agg["y_base"]).values, width=width, label="P12 ACTIVE - Base"); plotted_any = True
if (not has_p12std and not has_p12act) and has_p12:
plt.bar(x, (agg["y_p12"] - agg["y_base"]).values, width=0.6, label="P12 - Base"); plotted_any = True
if plotted_any:
plt.xticks(x, xlab, rotation=45, ha="right")
plt.title("Delta mensual portfolio (P12 - Base)")
plt.ylabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "bar_delta_portfolio_p12.png"), dpi=150)
plt.show()
else:
plt.close()
print(f"[OK] Gráficos guardados en: {FIG_DIR}")
[OK] Gráficos guardados en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_compare_recon
Iteración 1 del paso 12. No necesita ejecución de nuevo.¶
Nos ha servido en la 1a iteración del paso 12 para detectar que se deben congelar los pares con base=0.
# ================================================
# Exploración gráfica: base==0 y reconciliado>0 (Paso 12)
# ================================================
# --- Rutas base (ajústalas si hiciera falta) ---
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
DATECOL = "FECHA"
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
# --- Cargar datos (Opción A: directamente del step12) ---
df12 = pd.read_csv(os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv"),
sep=CSV_SEP, parse_dates=[DATECOL])
# Nos aseguramos de que existan estas columnas
assert {"yhat_base","yhat_reconc"}.issubset(df12.columns), "Faltan columnas en preds_reconciliadas_step12.csv"
# Añadimos dimensiones para segmentar/gráficos
dim = pd.read_csv(os.path.join(RUTA_METRICAS, "dim_buildings.csv"), sep=CSV_SEP)
dim = dim[[c for c in ["ID_BUILDING","PAIS","REGION"] if c in dim.columns]].drop_duplicates()
df12 = df12.merge(dim, on="ID_BUILDING", how="left")
# --- (Opción B: usar el comparativo generado previamente) ---
# cmp_path = os.path.join(RUTA_REPORTING, "compare_recon_steps_bottom.csv")
# if os.path.exists(cmp_path):
# df_cmp = pd.read_csv(cmp_path, sep=CSV_SEP, parse_dates=[DATECOL])
# # Renombramos para tener las mismas columnas de interés
# df12 = (df_cmp.rename(columns={"yhat_base_ref":"yhat_base",
# "yhat_reconc_p12":"yhat_reconc"}))
# # Unimos PAIS/REGION si hace falta
# if "PAIS" not in df12.columns and "REGION" not in df12.columns:
# df12 = df12.merge(dim, on="ID_BUILDING", how="left")
# --- Filtro: base ~0 y reconc > 0 ---
TOL = 1e-9
mask = (df12["yhat_base"].abs() <= TOL) & (df12["yhat_reconc"] > TOL)
df_z2p = df12.loc[mask].copy() # zero-to-positive
print(f"[Info] Filas base≈0 & reconc>0: {len(df_z2p)} sobre {len(df12)} ({100*len(df_z2p)/max(1,len(df12)):.2f}%)")
# =======================
# 1) Resúmenes tabulares
# =======================
# Top por magnitud del cambio
df_z2p["delta"] = df_z2p["yhat_reconc"] - df_z2p["yhat_base"]
top_abs = df_z2p.assign(abs_delta=df_z2p["delta"].abs()) \
.sort_values("abs_delta", ascending=False) \
.head(20) \
[[*PAIR_COLS, DATECOL, "PAIS","REGION", "yhat_base","yhat_reconc","delta"]]
print("\n[Top 20 cambios absolutos en filas base≈0 → reconc>0]")
display(top_abs)
# Conteo por FM_COST_TYPE y PAIS
by_fm = df_z2p.groupby("FM_COST_TYPE").size().rename("count").reset_index().sort_values("count", ascending=False)
by_pais = (df_z2p.dropna(subset=["PAIS"])
.groupby("PAIS").size().rename("count").reset_index().sort_values("count", ascending=False))
print("\n[Conteo por FM_COST_TYPE]")
display(by_fm.head(20))
print("\n[Conteo por PAIS]")
display(by_pais.head(20))
# =======================
# 2) Gráficos de resumen
# =======================
FIG_DIR = os.path.join(RUTA_REPORTING, "figs_zero_to_pos_p12")
os.makedirs(FIG_DIR, exist_ok=True)
# 2.1 Histograma de yhat_reconc (solo casos base≈0)
plt.figure(figsize=(10,5))
plt.hist(df_z2p["yhat_reconc"].values, bins=40)
plt.title("Distribución de yhat_reconc para filas con base≈0 (Paso 12)")
plt.xlabel("yhat_reconc")
plt.ylabel("Frecuencia")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "hist_yhat_reconc_zero2pos.png"))
plt.show()
# 2.2 Boxplot de deltas por FM_COST_TYPE (solo con base≈0)
if not df_z2p.empty:
order_fm = (df_z2p.groupby("FM_COST_TYPE")["delta"]
.median().sort_values(ascending=False).index.tolist())
plt.figure(figsize=(12,6))
data = [df_z2p.loc[df_z2p["FM_COST_TYPE"]==fm, "delta"].values for fm in order_fm]
plt.boxplot(data, labels=order_fm, showfliers=False)
plt.axhline(0, linestyle=":", color="grey")
plt.title("Delta (reconc-base) por FM_COST_TYPE — casos base≈0")
plt.ylabel("Delta")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "box_delta_por_fm_zero2pos.png"))
plt.show()
# 2.3 Barras horizontales: Top 20 deltas absolutos
if not df_z2p.empty:
tops = df_z2p.assign(abs_delta=df_z2p["delta"].abs()).sort_values("abs_delta", ascending=False).head(20)
labels = tops.apply(lambda r: f"{int(r['ID_BUILDING'])}|{r['FM_COST_TYPE']}|{str(r.get('PAIS',''))}", axis=1)
plt.figure(figsize=(10,8))
plt.barh(range(len(tops)), tops["delta"])
plt.yticks(range(len(tops)), labels)
plt.gca().invert_yaxis()
plt.title("Top 20 ajustes (reconc - base) — base≈0")
plt.xlabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(FIG_DIR, "barh_top20_zero2pos.png"))
plt.show()
# =======================
# 3) Series temporales por ejemplo
# =======================
# Elegimos N pares con mayor delta total anual (suma de deltas en 2024)
if not df_z2p.empty:
# Sumamos por par en el año
annual_rank = (df_z2p.groupby(PAIR_COLS, as_index=False)["delta"]
.sum()
.sort_values("delta", ascending=False))
N = 6 # cuántos pares mostrar
sel_pairs = annual_rank.head(N)[PAIR_COLS].values.tolist()
for bid, fmc in sel_pairs:
sub = df12[(df12["ID_BUILDING"]==bid) & (df12["FM_COST_TYPE"]==fmc)].sort_values(DATECOL)
if sub.empty:
continue
plt.figure(figsize=(10,4))
plt.plot(sub[DATECOL], sub["yhat_base"], label="Base", linewidth=2, color="black")
plt.plot(sub[DATECOL], sub["yhat_reconc"], label="Reconc P12", linestyle="--")
pais = str(sub["PAIS"].dropna().iloc[0]) if sub["PAIS"].notna().any() else ""
plt.title(f"Serie par {bid} | {fmc} | {pais} — base≈0→reconc>0")
plt.xlabel("Mes"); plt.ylabel("Valor")
plt.legend()
plt.tight_layout()
fname = f"ts_pair_{bid}_{fmc.replace(' ','_')}.png"
plt.savefig(os.path.join(FIG_DIR, fname))
plt.show()
print(f"\n[OK] Figuras guardadas en: {FIG_DIR}")
[Info] Filas base≈0 & reconc>0: 6076 sobre 29148 (20.85%) [Top 20 cambios absolutos en filas base≈0 → reconc>0]
ID_BUILDING | FM_COST_TYPE | FECHA | PAIS | REGION | yhat_base | yhat_reconc | delta | |
---|---|---|---|---|---|---|---|---|
366 | 57 | Suministros | 2024-07-01 | ESPAÑA | 2 | 0.0 | 215342.684158 | 215342.684158 |
369 | 57 | Suministros | 2024-10-01 | ESPAÑA | 2 | 0.0 | 213564.718327 | 213564.718327 |
371 | 57 | Suministros | 2024-12-01 | ESPAÑA | 2 | 0.0 | 212051.463637 | 212051.463637 |
370 | 57 | Suministros | 2024-11-01 | ESPAÑA | 2 | 0.0 | 211174.773476 | 211174.773476 |
365 | 57 | Suministros | 2024-06-01 | ESPAÑA | 2 | 0.0 | 211157.703603 | 211157.703603 |
360 | 57 | Suministros | 2024-01-01 | ESPAÑA | 2 | 0.0 | 208990.996684 | 208990.996684 |
363 | 57 | Suministros | 2024-04-01 | ESPAÑA | 2 | 0.0 | 208908.538929 | 208908.538929 |
367 | 57 | Suministros | 2024-08-01 | ESPAÑA | 2 | 0.0 | 207536.664025 | 207536.664025 |
368 | 57 | Suministros | 2024-09-01 | ESPAÑA | 2 | 0.0 | 206879.542313 | 206879.542313 |
362 | 57 | Suministros | 2024-03-01 | ESPAÑA | 2 | 0.0 | 204969.967910 | 204969.967910 |
361 | 57 | Suministros | 2024-02-01 | ESPAÑA | 2 | 0.0 | 202098.068082 | 202098.068082 |
15937 | 1000415 | Licencias | 2024-02-01 | MÉXICO | 1000021 | 0.0 | 77576.679692 | 77576.679692 |
15938 | 1000415 | Licencias | 2024-03-01 | MÉXICO | 1000021 | 0.0 | 72893.872957 | 72893.872957 |
15947 | 1000415 | Licencias | 2024-12-01 | MÉXICO | 1000021 | 0.0 | 72040.460372 | 72040.460372 |
15943 | 1000415 | Licencias | 2024-08-01 | MÉXICO | 1000021 | 0.0 | 68461.478484 | 68461.478484 |
15936 | 1000415 | Licencias | 2024-01-01 | MÉXICO | 1000021 | 0.0 | 68266.574795 | 68266.574795 |
15946 | 1000415 | Licencias | 2024-11-01 | MÉXICO | 1000021 | 0.0 | 68206.299584 | 68206.299584 |
15945 | 1000415 | Licencias | 2024-10-01 | MÉXICO | 1000021 | 0.0 | 67520.897160 | 67520.897160 |
15942 | 1000415 | Licencias | 2024-07-01 | MÉXICO | 1000021 | 0.0 | 66599.834705 | 66599.834705 |
15939 | 1000415 | Licencias | 2024-04-01 | MÉXICO | 1000021 | 0.0 | 64080.028541 | 64080.028541 |
[Conteo por FM_COST_TYPE]
FM_COST_TYPE | count | |
---|---|---|
1 | Licencias | 2143 |
6 | Servicios Extra | 1555 |
2 | Mtto. Contratos | 964 |
3 | Mtto. Correctivo | 702 |
0 | Eficiencia Energética | 342 |
5 | Servicios Ctto. | 299 |
4 | Obras | 60 |
7 | Suministros | 11 |
[Conteo por PAIS]
PAIS | count | |
---|---|---|
2 | ESPAÑA | 5087 |
3 | MÉXICO | 364 |
5 | PERÚ | 349 |
4 | PANAMÁ | 167 |
0 | COLOMBIA | 77 |
6 | REPÚBLICA DOMINICANA | 18 |
1 | COSTA RICA | 14 |
[OK] Figuras guardadas en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/figs_zero_to_pos_p12
Visualizamos lo que se truncaron a 0 por que la conciliación los arrastraría hacia valores negativos.
Los 0 que han subido por la conciliación tampoco debieran, porque en principio las predicciones que son 0 vienen de naive12. Es decir, la conciliación se debería hacer solo para los pares y meses en los que el valor sea positivo y distinto de 0,.
Esisten ciertos pares/meses con base = 0 son “ceros estructurales” (derivados de evidencia histórica), no deberíamos inflarlos por el ajuste.
Para conseguirlo sin romper las sumas objetivo, hay que congelar esos nodos y reconciliar solo sobre el subconjunto “activo” (los que tienen base > 0 o por encima de un umbral pequeño).
Vamos a rediseñar el paso 12 anterior para congelar los 0 y generar una opción de reconciliación ACTIVE.
Definiremos un umbral ZERO_THR
(por ejemplo: 1e-9) para decidir qué es “cero”.
Separaremos cada mes en:
S (activos): y_b > ZERO_THR -> se reconcilian.
F (fijos): y_b ≤ ZERO_THR -> se mantienen tal cual (no cambian).
Reconciliamos solo en S con MinT, ajustando el objetivo por el aporte fijo de F: si A = [A_S A_F] y y = [y_S y_F], usa r = c - A_S y_S - A_F y_F y resuelve en S: Δ_S = W_S A_S' (A_S W_S A_S')^{-1} r.
Si en alguna fila agregada no queda ningún hijo activo (A_S es cero en esa fila), relaja esa restricción fijando su c_row = (A y_b)_row (autoconsistencia) para que el sistema no sea imposible.
Iteramos de nuevo el paso 12 con una nueva versión de código que permite evaluar STD (iteración 1) y ACTIVE (solo para pares con base<>0)¶
Revisamos cuantos pares se truncan en cada variante del Paso 12.¶
Para cada variante (STD y ACTIVE), intentamos cargar *_beforeclip.csv y *.csv (post-clip).
Si no existe el before, igualmente mostramos la distribución post-clip y realizamos el conteo de negativos después del clip.
Si existe el before, calculamos cuántos valores fueron negativos y cuántos quedaron truncados a 0 tras el clip, además de mostrarlos en el histograma “antes vs después”.
Finalmente mostramos una muestra de 10 pares truncados a 0 para una inspección rápida.
# ============================================================
# Paso 12 — Diagnóstico de negativos antes y después del clip
# Variantes: STD (todas las series) y ACTIVE (solo activos)
# ============================================================
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
DATECOL = "FECHA"
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
# --- Rutas de ambas variantes ---
PATHS = {
"STD": {
"after": os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12.csv"),
"before": os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_beforeclip.csv"),
},
"ACTIVE": {
"after": os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly.csv"),
"before": os.path.join(RUTA_RESULTADOS, "preds_reconciliadas_step12_activeonly_beforeclip.csv"),
}
}
def _load_pair(paths_variant, variant_name):
"""Carga BEFORE/AFTER para una variante, hace merge y devuelve df listo."""
p_after = paths_variant["after"]
p_before = paths_variant["before"]
if not os.path.exists(p_after):
print(f"[{variant_name}] AVISO: no existe AFTER: {p_after}. Se omite esta variante.")
return None
after = pd.read_csv(p_after, sep=CSV_SEP, parse_dates=[DATECOL]) \
.rename(columns={"yhat_reconc":"yhat_after"})
need_common = [*PAIR_COLS, DATECOL, "yhat_base", "yhat_after"]
missing = [c for c in need_common if c not in after.columns]
if missing:
raise KeyError(f"[{variant_name}] Faltan columnas en AFTER: {missing}")
if not os.path.exists(p_before):
print(f"[{variant_name}] AVISO: no existe BEFORE: {p_before}. "
f"Podrás ver negativos post-clip, pero no los truncados.")
# Creamos un df mínimo sin BEFORE para poder contar negativos post-clip
df_min = after.copy()
df_min["yhat_before"] = np.nan
df_min["was_negative"] = False
df_min["truncated_to0"] = False
return df_min
before = pd.read_csv(p_before, sep=CSV_SEP, parse_dates=[DATECOL]) \
.rename(columns={"yhat_reconc":"yhat_before"})
need_b = [*PAIR_COLS, DATECOL, "yhat_base", "yhat_before"]
miss_b = [c for c in need_b if c not in before.columns]
if miss_b:
raise KeyError(f"[{variant_name}] Faltan columnas en BEFORE: {miss_b}")
df = before.merge(after, on=[*PAIR_COLS, DATECOL, "yhat_base"], how="inner")
df["was_negative"] = df["yhat_before"] < 0
df["truncated_to0"] = df["was_negative"] & (df["yhat_after"] == 0)
return df
def _report_variant(df, variant_name):
"""Imprime resumen, dibuja histogramas y muestra una muestra de truncados."""
if df is None:
return
total = len(df)
neg_after = int((df["yhat_after"] < 0).sum()) if "yhat_after" in df else 0
has_before = df["yhat_before"].notna().any()
print(f"\n===== {variant_name} =====")
print(f"[Info] Total filas: {total}")
if has_before:
print(f"[Info] Negativos ANTES del clip: {int(df['was_negative'].sum())}")
print(f"[Info] Truncados a 0: {int(df['truncated_to0'].sum())}")
else:
print("[Info] BEFORE no disponible: no se puede contar truncados.")
print(f"[Info] Negativos DESPUÉS del clip: {neg_after} (esperado 0)")
# Histogramas
if has_before:
plt.figure(figsize=(10,5))
plt.hist(df["yhat_before"], bins=50, alpha=0.5, label="Antes del clip")
plt.hist(df["yhat_after"], bins=50, alpha=0.5, label="Después del clip")
plt.axvline(0, color="black", linestyle="--")
plt.title(f"Paso 12 — {variant_name}: distribución antes vs. después del clip")
plt.xlabel("Valor predicho"); plt.ylabel("Frecuencia")
plt.legend(); plt.tight_layout(); plt.show()
else:
plt.figure(figsize=(10,5))
plt.hist(df["yhat_after"], bins=50, alpha=0.8, label="Después del clip")
plt.axvline(0, color="black", linestyle="--")
plt.title(f"Paso 12 — {variant_name}: distribución (solo post-clip)")
plt.xlabel("Valor predicho"); plt.ylabel("Frecuencia")
plt.legend(); plt.tight_layout(); plt.show()
# Muestra de truncados
if has_before and df["truncated_to0"].any():
sample = df[df["truncated_to0"]].sample(min(10, df["truncated_to0"].sum()), random_state=1)
print("\n[Muestra de pares truncados a 0]")
display(sample[[*PAIR_COLS, DATECOL, "yhat_base", "yhat_before", "yhat_after"]])
# --- Ejecutamos para ambas variantes ---
df_std = _load_pair(PATHS["STD"], "STD")
df_active = _load_pair(PATHS["ACTIVE"], "ACTIVE")
_report_variant(df_std, "STD")
_report_variant(df_active, "ACTIVE")
===== STD ===== [Info] Total filas: 29148 [Info] Negativos ANTES del clip: 2684 [Info] Truncados a 0: 2684 [Info] Negativos DESPUÉS del clip: 0 (esperado 0)
[Muestra de pares truncados a 0]
ID_BUILDING | FM_COST_TYPE | FECHA | yhat_base | yhat_before | yhat_after | |
---|---|---|---|---|---|---|
424 | 59 | Servicios Ctto. | 2024-05-01 | 0.000000 | -60942.801401 | 0.0 |
2848 | 162 | Servicios Ctto. | 2024-05-01 | 0.000000 | -169.386250 | 0.0 |
2429 | 149 | Eficiencia Energética | 2024-06-01 | 0.000000 | -4.271168 | 0.0 |
894 | 118 | Mtto. Contratos | 2024-07-01 | 815.071843 | -3044.492192 | 0.0 |
7253 | 594 | Obras | 2024-06-01 | 0.000000 | -40.977911 | 0.0 |
1918 | 137 | Servicios Extra | 2024-11-01 | 0.000000 | -222.957688 | 0.0 |
1046 | 122 | Mtto. Correctivo | 2024-03-01 | 473.190000 | -253.472537 | 0.0 |
15681 | 1000409 | Obras | 2024-10-01 | 0.000000 | -126.063232 | 0.0 |
11153 | 1190 | Suministros | 2024-06-01 | 0.000000 | -9177.544270 | 0.0 |
1495 | 131 | Servicios Ctto. | 2024-08-01 | 170.848607 | -3755.372832 | 0.0 |
===== ACTIVE ===== [Info] Total filas: 29148 [Info] Negativos ANTES del clip: 928 [Info] Truncados a 0: 928 [Info] Negativos DESPUÉS del clip: 0 (esperado 0)
[Muestra de pares truncados a 0]
ID_BUILDING | FM_COST_TYPE | FECHA | yhat_base | yhat_before | yhat_after | |
---|---|---|---|---|---|---|
548 | 74 | Servicios Extra | 2024-09-01 | 314.546806 | -40.241430 | 0.0 |
897 | 118 | Mtto. Contratos | 2024-10-01 | 814.126649 | -4458.569397 | 0.0 |
3519 | 178 | Mtto. Contratos | 2024-04-01 | 2.444412 | -941.056016 | 0.0 |
13966 | 1000026 | Suministros | 2024-11-01 | 21.711000 | -14394.427254 | 0.0 |
576 | 104 | Mtto. Contratos | 2024-01-01 | 692.879722 | -53.342613 | 0.0 |
119 | 9 | Licencias | 2024-12-01 | 314.713498 | -1358.463723 | 0.0 |
1616 | 133 | Mtto. Correctivo | 2024-09-01 | 71.143703 | -247.928789 | 0.0 |
739 | 116 | Mtto. Contratos | 2024-08-01 | 214.830000 | -129.058950 | 0.0 |
498 | 74 | Mtto. Contratos | 2024-07-01 | 11.917100 | -31007.792924 | 0.0 |
259 | 18 | Servicios Ctto. | 2024-08-01 | 16859.044681 | -3050.944156 | 0.0 |
Interpretación¶
STD: vimos 2,684 valores negativos antes del clip sobre 29,148 filas -> ~9.2% truncados a 0.
ACTIVE: se reduce a 928/29,148 -> ~3.2% truncados a 0.
Después del clip en ambas variantes: 0 negativos (lo cual es correcto).
En conclusión:
El modo STD genera muchos más negativos previos (lógico: incluye pares con base=0 o casi 0, y ahí la reconciliación puede empujar por debajo de cero).
El modo ACTIVE reduce bastante ese efecto porque se centra en activos (menos pares “vacíos” o con base ínfima).
La muestra de truncados confirma el patrón: aparecen varios casos con yhat_base=0 (p. ej. Obras, Suministros, Servicios Ctto., Servicios Extra) y algunos con base pequeña pero negativa fuerte antes del clip (reconciliación agresiva en esos nodos).
Contrastamos comparación añadiendo la realidad de 2024.¶
Ahora queremos dar un paso más: no solo comparar P10, P11, P12 STD y P12 ACTIVE entre sí, sino también vs. los datos reales del 2024.
Leemos el test_full_2024.csv (nuestra verdad-terreno o real 2024).
Uniremos ese dataset con los reconciliados de pasos 10, 11 y 12 (std y active).
Calculamos las métricas simples de error contra la realidad (ej. deltas absolutos, relativos, MAPE, RMSE).
Exportamos resultados a CSV y mostramos un resumen rápido.
# ============================================================
# Comparación contra datos reales 2024
# - Requiere:
# RESULTADOS/test_full_2024.csv (real)
# REPORTING/compare_recon_steps_bottom.csv (predicciones P10, P11, P12 STD/ACTIVE)
# ============================================================
# --- Rutas
PATH_REAL = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv") # verdad terreno 2024
PATH_BOTTOM = os.path.join(RUTA_REPORTING, "compare_recon_steps_bottom.csv")
OUT_METRICS = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_metrics.csv")
OUT_BOTTOM = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_bottom.csv")
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
# --- Lectura real
real_df = pd.read_csv(PATH_REAL, sep=CSV_SEP, parse_dates=[DATECOL])
if "cost_float_mod" not in real_df.columns:
raise KeyError("Esperábamos columna 'cost_float_mod' en test_full_2024.csv (valor real).")
# --- Lectura predicciones reconciliadas
pred_df = pd.read_csv(PATH_BOTTOM, sep=CSV_SEP, parse_dates=[DATECOL])
# --- Merge
df = pred_df.merge(
real_df[PAIR_COLS+[DATECOL,"cost_float_mod"]],
on=PAIR_COLS+[DATECOL], how="left"
).rename(columns={"cost_float_mod":"y_real"})
# --- Calcular errores por cada paso
def _calc_errors(df, col_pred, col_real="y_real"):
err = df[col_pred] - df[col_real]
abs_ = err.abs()
return {
"MAE": abs_.mean(),
"RMSE": np.sqrt((err**2).mean()),
"MAPE%": (abs_ / df[col_real].replace(0,np.nan)).mean()*100,
"Bias": err.mean()
}
metrics = {}
for col in ["yhat_reconc_p10","yhat_reconc_p11","yhat_reconc_p12std","yhat_reconc_p12act"]:
if col in df.columns:
metrics[col] = _calc_errors(df, col)
metrics_df = pd.DataFrame(metrics).T
metrics_df.index.name = "Modelo"
metrics_df.to_csv(OUT_METRICS, sep=CSV_SEP)
print(f"[OK] Guardadas métricas comparativas en {OUT_METRICS}")
print(metrics_df)
# --- Export bottom con errores fila a fila (opcional)
keep_cols = [*PAIR_COLS, DATECOL, "y_real"]
for col in ["yhat_reconc_p10","yhat_reconc_p11","yhat_reconc_p12std","yhat_reconc_p12act"]:
if col in df.columns:
keep_cols.append(col)
df[f"error_abs_{col}"] = (df[col] - df["y_real"]).abs()
keep_cols.append(f"error_abs_{col}")
df[keep_cols].to_csv(OUT_BOTTOM, sep=CSV_SEP, index=False)
print(f"[OK] Guardado bottom con errores: {OUT_BOTTOM}")
[OK] Guardadas métricas comparativas en /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_metrics.csv MAE RMSE MAPE% Bias Modelo yhat_reconc_p10 500.917395 2094.018508 91.919262 -3.830971 yhat_reconc_p11 539.231234 2428.586784 89.970338 -122.757539 yhat_reconc_p12std 814.768026 3830.653335 236.143547 112.612548 yhat_reconc_p12act 738.094872 3298.421851 202.837720 49.644224 [OK] Guardado bottom con errores: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_bottom.csv
Los resultados que obtenemos nos dan una visión muy clara de cómo se comportan los distintos pasos frente a los datos reales del 2024 a nivel global con todos los pares.
- Paso 10 (
yhat_reconc_p10
)
MAE = 500.9, RMSE = 2094.0, MAPE% ≈ 91.9%
Bias ≈ -3.8 (casi nulo)
Es el más equilibrado: aunque el error relativo (MAPE) es alto, mantiene un sesgo casi cero, lo que significa que no tiende ni a sobreestimar ni a infraestimar de manera sistemática. Es estable y consistente.
- Paso 11 (
yhat_reconc_p11
)
MAE = 539.2, RMSE = 2428.6, MAPE% ≈ 90.0%
Bias = -122.8 (infraestima) Similar al paso 10 en MAPE, pero con algo más de error absoluto y un sesgo negativo: tiende a quedarse corto en las predicciones. Eso lo hace algo menos balanceado que el paso 10.
- Paso 12 estándar (yhat_reconc_p12std)
MAE = 814.8, RMSE = 3830.7, MAPE% ≈ 236.1%
Bias = +112.6 (sobreestima) Aquí vemos que, aunque el ajuste jerárquico fuerza coherencia con los anclajes, se dispara el error relativo. Tiene una fuerte sobreestimación en muchos casos (Bias positivo). Es robusto a nivel estructural, pero pierde precisión frente al dato real.
- Paso 12 solo activos (yhat_reconc_p12act)
MAE = 738.1, RMSE = 3298.4, MAPE% ≈ 202.8%
Bias = +49.6 (ligera sobreestimación) Mejora frente al estándar porque al congelar los “ceros estructurales” reduce distorsiones extremas. Aun así, el error relativo sigue siendo muy alto. Se comporta mejor que el STD, pero no alcanza a P10 o P11.
Comparación global
Más fiables en error absoluto y relativo: Paso 10 y Paso 11.
Más coherentes estructuralmente: Paso 12, pero a costa de precisión.
Ahora vamos a bajar un nivel y vamos a obtener las métricas según PAIS
y FM_COST_TYPE
.
Incorporamos MASE y WAPE para
# ============================================================
# Métricas vs Real 2024: MAE/RMSE/Bias/MAPE + sMAPE/WAPE + MASE/WASE
# - Global, por PAIS, por FM_COST_TYPE, y PAIS×FM_COST_TYPE
# Requiere:
# RESULTADOS/test_full_2024.csv
# REPORTING/compare_recon_steps_bottom.csv
# RESULTADOS/train_full_2021_2023.csv (para MASE)
# METRICAS/dim_buildings.csv (para PAIS)
# ============================================================
# --- Rutas base ---
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
CSV_SEP = ";"
DATECOL = "FECHA"
PAIR_COLS = ["ID_BUILDING","FM_COST_TYPE"]
RUTA_RESULTADOS = os.path.join(ruta_base_3, "RESULTADOS")
RUTA_METRICAS = os.path.join(ruta_base_3, "METRICAS")
RUTA_REPORTING = os.path.join(ruta_base_3, "REPORTING")
os.makedirs(RUTA_REPORTING, exist_ok=True)
PATH_REAL = os.path.join(RUTA_RESULTADOS, "test_full_2024.csv")
PATH_BOTTOM = os.path.join(RUTA_REPORTING, "compare_recon_steps_bottom.csv")
PATH_TRAIN = os.path.join(RUTA_RESULTADOS, "train_full_2021_2023.csv")
PATH_DIM = os.path.join(RUTA_METRICAS, "dim_buildings.csv")
OUT_OVERALL = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_metrics_overall.csv")
OUT_PAISES = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_metrics_by_pais.csv")
OUT_FMCOST = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_metrics_by_fmcost.csv")
OUT_GRID = os.path.join(RUTA_REPORTING, "compare_recon_vs_real_metrics_by_pais_fmcost.csv")
# --- Lecturas robustas ---
def _read_csv(path, parse_dates=None, required=True):
if not os.path.exists(path):
if required:
raise FileNotFoundError(f"Falta {path}")
return pd.DataFrame()
return pd.read_csv(path, sep=CSV_SEP, parse_dates=parse_dates)
real_df = _read_csv(PATH_REAL, parse_dates=[DATECOL], required=True)
pred_df = _read_csv(PATH_BOTTOM, parse_dates=[DATECOL], required=True)
train_df = _read_csv(PATH_TRAIN, parse_dates=[DATECOL], required=True)
dim_df = _read_csv(PATH_DIM, required=False)
# --- Chequeo columnas clave ---
if "cost_float_mod" not in real_df.columns:
raise KeyError("Esperábamos 'cost_float_mod' en test_full_2024.csv")
need_preds = set(PAIR_COLS+[DATECOL])
if not need_preds.issubset(pred_df.columns):
raise KeyError(f"compare_recon_steps_bottom.csv debe contener {need_preds}")
pred_cols = [c for c in ["yhat_reconc_p10","yhat_reconc_p11","yhat_reconc_p12std","yhat_reconc_p12act"] if c in pred_df.columns]
if not pred_cols:
raise KeyError("No se encontraron columnas de predicción (p10/p11/p12std/p12act) en compare_recon_steps_bottom.csv")
# --- Merge predicciones con verdad 2024 ---
df = pred_df.merge(
real_df[PAIR_COLS+[DATECOL,"cost_float_mod"]],
on=PAIR_COLS+[DATECOL], how="left"
).rename(columns={"cost_float_mod":"y_real"})
# --- Denominador MASE por pareja (naive estacional 12) usando 2021-2023 ---
train = train_df.copy()
train[DATECOL] = pd.to_datetime(train[DATECOL]).dt.to_period("M").dt.to_timestamp()
if "cost_float_mod" not in train.columns:
raise KeyError("Esperábamos 'cost_float_mod' en train_full_2021_2023.csv")
train = train.sort_values(PAIR_COLS+[DATECOL])
train["y_lag12"] = train.groupby(PAIR_COLS)["cost_float_mod"].shift(12)
train["abs_diff12"] = (train["cost_float_mod"] - train["y_lag12"]).abs()
scale = (train.groupby(PAIR_COLS, as_index=False)["abs_diff12"]
.mean()
.rename(columns={"abs_diff12":"MASE_scale"}))
# Fallback si el denominador falta o es ~0
scale["MASE_scale"] = pd.to_numeric(scale["MASE_scale"], errors="coerce").fillna(0.0)
min_eps = 1e-8
scale.loc[scale["MASE_scale"] < min_eps, "MASE_scale"] = min_eps
# Unimos el denominador a df 2024
df = df.merge(scale, on=PAIR_COLS, how="left")
df["MASE_scale"] = df["MASE_scale"].fillna(1.0) # último fallback
# --- Funciones de métricas ---
def _safe_div(a, b):
return a / np.where(b==0, np.nan, b)
def _metrics_block(sub, y_col_pred, y_col_real="y_real", scale_col="MASE_scale"):
y_real = sub[y_col_real].astype(float)
y_pred = sub[y_col_pred].astype(float)
err = y_pred - y_real
abs_e = err.abs()
# clásicos
mse = (err**2).mean()
rmse = np.sqrt(mse) if np.isfinite(mse) else np.nan
mape = (100.0 * _safe_div(abs_e, y_real.abs())).mean()
bias = err.mean()
mae = abs_e.mean()
# sMAPE y WAPE
smape = (100.0 * _safe_div(abs_e, (y_real.abs() + y_pred.abs())/2.0)).mean()
wape = 100.0 * abs_e.sum() / (y_real.abs().sum() if y_real.abs().sum()!=0 else np.nan)
# MASE / WASE con escala por pareja
scale_vals = sub[scale_col].astype(float).replace(0, np.nan)
mase = (abs_e / scale_vals).mean()
wase = abs_e.sum() / scale_vals.sum()
return pd.Series({
"n": len(sub),
"MAE": mae,
"RMSE": rmse,
"MAPE%": mape,
"sMAPE%": smape,
"WAPE%": wape,
"Bias": bias,
"MASE": mase,
"WASE": wase,
})
def _compute_by(df_in, group_cols):
"""
Calcula métricas por grupo. Si group_cols es vacío/None, calcula global sin groupby.
"""
rows = []
def _one_group(g, key_vals):
for col in pred_cols:
met = _metrics_block(g, col)
# construimos fila
if group_cols:
base = {k: v for k, v in zip(group_cols, key_vals)}
else:
base = {}
row = {**base, "Modelo": col, **met.to_dict()}
rows.append(row)
if not group_cols:
_one_group(df_in, ())
else:
for key_vals, g in df_in.groupby(group_cols, dropna=False):
if not isinstance(key_vals, tuple):
key_vals = (key_vals,)
_one_group(g, key_vals)
return pd.DataFrame(rows)
# --- Añadimos PAIS desde dim_buildings (si existe) ---
if not dim_df.empty and "PAIS" in dim_df.columns:
dim_keep = dim_df[["ID_BUILDING","PAIS"]].drop_duplicates()
df = df.merge(dim_keep, on="ID_BUILDING", how="left")
else:
df["PAIS"] = np.nan
# --- Cálculos de métricas ---
overall = _compute_by(df, []) # GLOBAL sin groupby
by_pais = _compute_by(df, ["PAIS"])
by_fmc = _compute_by(df, ["FM_COST_TYPE"])
by_grid = _compute_by(df, ["PAIS","FM_COST_TYPE"])
# --- Orden y export ---
def _nice_order(d):
metric_cols = ["n","MAE","RMSE","MAPE%","sMAPE%","WAPE%","Bias","MASE","WASE"]
lead_cols = [c for c in ["PAIS","FM_COST_TYPE"] if c in d.columns]
return d[lead_cols + ["Modelo"] + metric_cols]
overall = _nice_order(overall.sort_values(["Modelo"]))
by_pais = _nice_order(by_pais.sort_values(["PAIS","Modelo"]))
by_fmc = _nice_order(by_fmc.sort_values(["FM_COST_TYPE","Modelo"]))
by_grid = _nice_order(by_grid.sort_values(["PAIS","FM_COST_TYPE","Modelo"]))
overall.to_csv(OUT_OVERALL, sep=CSV_SEP, index=False)
by_pais.to_csv(OUT_PAISES, sep=CSV_SEP, index=False)
by_fmc.to_csv(OUT_FMCOST, sep=CSV_SEP, index=False)
by_grid.to_csv(OUT_GRID, sep=CSV_SEP, index=False)
print("[OK] Métricas globales ->", OUT_OVERALL)
print("[OK] Métricas por país ->", OUT_PAISES)
print("[OK] Métricas por FM_COST_TYPE ->", OUT_FMCOST)
print("[OK] Métricas por país×FM_COST_TYPE ->", OUT_GRID)
print("\n[Resumen Global]\n", overall)
[OK] Métricas globales -> /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_metrics_overall.csv [OK] Métricas por país -> /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_metrics_by_pais.csv [OK] Métricas por FM_COST_TYPE -> /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_metrics_by_fmcost.csv [OK] Métricas por país×FM_COST_TYPE -> /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/REPORTING/compare_recon_vs_real_metrics_by_pais_fmcost.csv [Resumen Global] Modelo n MAE RMSE MAPE% \ 0 yhat_reconc_p10 29148.0 500.917395 2094.018508 98.351446 1 yhat_reconc_p11 29148.0 539.231234 2428.586784 96.567198 3 yhat_reconc_p12act 29148.0 738.094872 3298.421851 209.405188 2 yhat_reconc_p12std 29148.0 814.768026 3830.653335 242.672793 sMAPE% WAPE% Bias MASE WASE 0 105.824619 33.330729 -3.830971 7.874199e+07 0.810279 1 95.392406 35.880108 -122.757539 1.405050e+08 0.872255 3 97.440512 49.112370 49.644224 1.419558e+08 1.193934 2 110.956662 54.214153 112.612548 3.082074e+08 1.317960
# --- Lecturas rápidas de los CSV de salida para verlos en consola ---
over_v = pd.read_csv(OUT_OVERALL, sep=CSV_SEP)
pais_v = pd.read_csv(OUT_PAISES, sep=CSV_SEP)
fmc_v = pd.read_csv(OUT_FMCOST, sep=CSV_SEP)
grid_v = pd.read_csv(OUT_GRID, sep=CSV_SEP)
print("\n[Resumen Global]")
print(over_v.sort_values(["Modelo"]).to_string(index=False, max_cols=None))
print("\n[Top 10 por país (ordenado por MAE, asc)]")
print(pais_v.sort_values(["PAIS","MAE"], ascending=[True, True]).groupby("PAIS").head(10).to_string(index=False))
print("\n[Top 10 por FM_COST_TYPE (ordenado por MAE, asc)]")
print(fmc_v.sort_values(["FM_COST_TYPE","MAE"], ascending=[True, True]).groupby("FM_COST_TYPE").head(10).to_string(index=False))
print("\n[Cuadrícula PAIS × FM_COST_TYPE (Top 5 por grupo por MAE, asc)]")
tmp = grid_v.sort_values(["PAIS","FM_COST_TYPE","MAE"], ascending=[True, True, True]) \
.groupby(["PAIS","FM_COST_TYPE"]).head(5)
print(tmp.to_string(index=False))
[Resumen Global] Modelo n MAE RMSE MAPE% sMAPE% WAPE% Bias MASE WASE yhat_reconc_p10 29148.0 500.917395 2094.018508 98.351446 105.824619 33.330729 -3.830971 7.874199e+07 0.810279 yhat_reconc_p11 29148.0 539.231234 2428.586784 96.567198 95.392406 35.880108 -122.757539 1.405050e+08 0.872255 yhat_reconc_p12act 29148.0 738.094872 3298.421851 209.405188 97.440512 49.112370 49.644224 1.419558e+08 1.193934 yhat_reconc_p12std 29148.0 814.768026 3830.653335 242.672793 110.956662 54.214153 112.612548 3.082074e+08 1.317960 [Top 10 por país (ordenado por MAE, asc)] PAIS Modelo n MAE RMSE MAPE% sMAPE% WAPE% Bias MASE WASE COLOMBIA yhat_reconc_p10 2904.0 1110.301675 3327.725852 135.601404 80.382058 36.876977 -582.826995 1.728318e+00 1.865126 COLOMBIA yhat_reconc_p11 2904.0 1564.163061 5906.984486 134.915358 80.641754 51.951291 -1133.107565 2.068377e+00 2.627540 COLOMBIA yhat_reconc_p12act 2904.0 1571.493727 5916.075444 136.047401 81.452745 52.194768 -1142.838336 2.149696e+00 2.639854 COLOMBIA yhat_reconc_p12std 2904.0 1657.316940 6015.896934 141.093122 86.428769 55.045255 -1271.484523 2.428517e+00 2.784023 COSTA RICA yhat_reconc_p11 588.0 330.501355 1244.124553 66.729904 62.366822 75.116375 160.340370 7.008589e-01 0.291624 COSTA RICA yhat_reconc_p12std 588.0 333.771506 1243.616478 69.850223 62.534155 75.859615 168.437277 7.175600e-01 0.294509 COSTA RICA yhat_reconc_p12act 588.0 334.109946 1244.943597 69.686357 62.691095 75.936536 167.513568 7.197277e-01 0.294808 COSTA RICA yhat_reconc_p10 588.0 349.531707 1399.918073 67.184554 61.385723 79.441595 195.201343 7.050406e-01 0.308415 ESPAÑA yhat_reconc_p10 18924.0 258.619590 992.707699 99.764514 126.213381 46.307968 54.279866 9.409426e+07 0.546679 ESPAÑA yhat_reconc_p11 18924.0 286.861525 1324.770131 99.709430 115.210209 51.364919 16.829146 1.919598e+08 0.606378 ESPAÑA yhat_reconc_p12act 18924.0 562.318281 3024.229004 235.705090 118.494114 100.687721 250.584846 1.919638e+08 1.188648 ESPAÑA yhat_reconc_p12std 18924.0 623.166062 3421.857688 280.517500 131.270424 111.583017 324.621084 3.523202e+08 1.317270 MÉXICO yhat_reconc_p11 2304.0 1047.056997 2385.452758 67.461204 57.194380 22.821076 -205.825754 6.106580e+07 0.873972 MÉXICO yhat_reconc_p12act 2304.0 1077.686595 2410.677660 91.398867 57.645617 23.488662 -167.814686 6.106581e+07 0.899539 MÉXICO yhat_reconc_p10 2304.0 1103.906774 2540.256547 73.423184 65.386543 24.060142 -45.551501 6.242629e+07 0.921424 MÉXICO yhat_reconc_p12std 2304.0 1435.936127 5592.044460 220.090347 74.211843 31.296871 187.202941 1.134584e+08 1.198567 PANAMÁ yhat_reconc_p11 2736.0 671.403211 1652.268449 80.616057 50.322384 19.069255 -145.348814 1.341920e+00 0.717190 PANAMÁ yhat_reconc_p10 2736.0 771.228764 2126.552452 86.947612 54.026495 21.904509 17.229607 1.446692e+00 0.823824 PANAMÁ yhat_reconc_p12std 2736.0 853.673028 2111.931703 314.025898 56.633059 24.246099 44.919143 4.703995e+01 0.911890 PANAMÁ yhat_reconc_p12act 2736.0 863.051542 2240.553438 346.300820 52.017629 24.512468 57.354144 6.109035e+01 0.921909 PERÚ yhat_reconc_p11 1392.0 286.970929 688.551255 80.158217 84.072905 23.830577 -38.976733 2.178934e+08 0.609317 PERÚ yhat_reconc_p12act 1392.0 288.011392 686.696352 81.156963 83.640964 23.916979 -33.543776 2.178934e+08 0.611526 PERÚ yhat_reconc_p10 1392.0 290.780812 712.416878 82.601795 102.532918 24.146956 -20.062427 2.343858e+08 0.617406 PERÚ yhat_reconc_p12std 1392.0 300.605929 683.122214 82.992855 103.784851 24.962851 -16.052080 1.438022e+09 0.638268 REPÚBLICA DOMINICANA yhat_reconc_p12std 300.0 2339.610106 6495.210832 83.084722 86.723633 51.139081 1172.930698 2.886056e+08 1.159960 REPÚBLICA DOMINICANA yhat_reconc_p11 300.0 2342.340228 6505.162690 76.324739 76.989970 51.198755 1177.299541 1.839465e+08 1.161313 REPÚBLICA DOMINICANA yhat_reconc_p12act 300.0 2349.269232 6504.275674 85.284051 81.769871 51.350209 1184.908055 3.244241e+08 1.164749 REPÚBLICA DOMINICANA yhat_reconc_p10 300.0 3398.151843 12421.437106 80.110567 83.144414 74.276633 1969.175522 1.843750e+08 1.684776 [Top 10 por FM_COST_TYPE (ordenado por MAE, asc)] FM_COST_TYPE Modelo n MAE RMSE MAPE% sMAPE% WAPE% Bias MASE WASE Eficiencia Energética yhat_reconc_p10 840.0 138.898582 593.175398 32.095701 66.453216 27.413572 14.517002 1.146632e+09 0.416257 Eficiencia Energética yhat_reconc_p11 840.0 207.510397 784.715757 34.039350 41.379164 40.955070 -38.297901 3.781648e+09 0.621876 Eficiencia Energética yhat_reconc_p12act 840.0 229.986726 782.057432 56.347817 48.233333 45.391087 1.691593 3.781648e+09 0.689234 Eficiencia Energética yhat_reconc_p12std 840.0 330.154482 813.217197 66.958533 86.580553 65.160591 118.079773 1.029452e+10 0.989421 Licencias yhat_reconc_p11 3096.0 56.584079 239.244704 100.388030 193.992326 175.573061 -2.311593 3.429715e+08 0.573533 Licencias yhat_reconc_p10 3096.0 57.390285 215.891907 93.480921 195.463468 178.074613 3.388258 3.527779e+08 0.581705 Licencias yhat_reconc_p12act 3096.0 78.238391 371.983199 120.561188 193.341006 242.763582 20.965428 3.429715e+08 0.793020 Licencias yhat_reconc_p12std 3096.0 585.136595 5343.602401 1537.138234 196.424203 1815.602991 532.092650 4.577426e+08 5.930913 Mtto. Contratos yhat_reconc_p10 4380.0 122.031691 300.622975 56.254698 125.071811 31.493601 -1.944875 1.177952e+07 1.188278 Mtto. Contratos yhat_reconc_p11 4380.0 127.218634 312.705883 58.458333 114.418880 32.832232 -36.486314 1.175214e+07 1.238786 Mtto. Contratos yhat_reconc_p12std 4380.0 584.979000 2444.441331 418.849803 128.626188 150.969759 420.762737 1.843884e+07 5.696206 Mtto. Contratos yhat_reconc_p12act 4380.0 671.215517 3246.446273 555.666321 119.947555 173.225441 501.265303 2.072728e+07 6.535931 Mtto. Correctivo yhat_reconc_p10 5364.0 544.749446 1096.758884 176.876199 110.881061 71.932376 12.158834 3.380292e+07 0.952662 Mtto. Correctivo yhat_reconc_p11 5364.0 550.574494 1105.905509 176.724690 107.939244 72.701555 -13.891866 3.391718e+07 0.962848 Mtto. Correctivo yhat_reconc_p12act 5364.0 621.713616 1522.150384 189.925199 109.885251 82.095243 36.046911 3.391718e+07 1.087257 Mtto. Correctivo yhat_reconc_p12std 5364.0 623.831565 1338.521291 214.187997 110.271154 82.374911 76.161893 3.928932e+07 1.090961 Obras yhat_reconc_p10 1080.0 282.138998 738.096242 76.988315 145.838566 32.942737 -43.070322 9.228150e-01 0.386801 Obras yhat_reconc_p11 1080.0 709.693403 3124.609779 226.693472 139.317273 82.864275 -249.691655 1.206986e+00 0.972960 Obras yhat_reconc_p12act 1080.0 973.446855 3300.389459 391.347413 140.066773 113.660304 33.929918 7.940854e+00 1.334555 Obras yhat_reconc_p12std 1080.0 2212.621623 8886.225726 1163.678566 142.280306 258.347175 1219.591144 8.035125e+07 3.033412 Servicios Ctto. yhat_reconc_p11 4056.0 332.245934 907.199089 56.084223 88.347319 15.961314 -138.322589 2.592835e+06 0.726376 Servicios Ctto. yhat_reconc_p10 4056.0 356.153235 892.424560 63.957686 91.877839 17.109836 -4.491260 2.648529e+06 0.778644 Servicios Ctto. yhat_reconc_p12act 4056.0 659.857585 2862.296816 253.098549 92.470325 31.699994 73.043492 2.609691e+06 1.442620 Servicios Ctto. yhat_reconc_p12std 4056.0 894.962060 4790.594207 425.310706 94.887415 42.994568 320.505763 2.623268e+06 1.956620 Servicios Extra yhat_reconc_p11 4512.0 265.447316 2089.690435 142.358710 140.988843 94.663755 -91.962747 8.340545e+07 1.429719 Servicios Extra yhat_reconc_p10 4512.0 286.939249 1611.465887 166.897301 153.699864 102.328203 5.119555 8.365123e+07 1.545476 Servicios Extra yhat_reconc_p12std 4512.0 407.687960 3138.208678 215.886134 156.548326 145.389578 62.198957 8.489535e+07 2.195838 Servicios Extra yhat_reconc_p12act 4512.0 427.433973 3692.681173 224.447248 141.016068 152.431396 76.870081 8.340557e+07 2.302191 Suministros yhat_reconc_p10 5820.0 1250.727017 4126.457572 58.803759 43.668852 28.888309 -28.838059 1.252266e+00 0.720285 Suministros yhat_reconc_p11 5820.0 1420.067483 4704.964679 62.097654 44.730558 32.799601 -351.362720 1.413498e+00 0.817808 Suministros yhat_reconc_p12std 5820.0 1475.481092 4783.799018 61.784262 48.248719 34.079501 -441.806131 1.501874e+00 0.849720 Suministros yhat_reconc_p12act 5820.0 1494.474387 4926.126118 66.710955 45.674310 34.518194 -305.132378 1.791503e+00 0.860658 [Cuadrícula PAIS × FM_COST_TYPE (Top 5 por grupo por MAE, asc)] PAIS FM_COST_TYPE Modelo n MAE RMSE MAPE% sMAPE% WAPE% Bias MASE WASE COLOMBIA Licencias yhat_reconc_p10 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Licencias yhat_reconc_p11 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Licencias yhat_reconc_p12act 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Licencias yhat_reconc_p12std 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Mtto. Contratos yhat_reconc_p11 48.0 8.602605e+01 1.564903e+02 34.382645 93.474635 77.188585 -3.227475e+01 2.467171e+00 2.145381e+00 COLOMBIA Mtto. Contratos yhat_reconc_p10 48.0 8.950868e+01 1.501993e+02 40.387405 105.428891 80.313448 -1.893927e+01 2.567026e+00 2.232234e+00 COLOMBIA Mtto. Contratos yhat_reconc_p12act 48.0 1.524254e+02 2.348966e+02 93.606815 93.873738 136.766749 9.526720e+01 3.809845e+00 3.801298e+00 COLOMBIA Mtto. Contratos yhat_reconc_p12std 48.0 1.885530e+02 2.489393e+02 70.678136 111.779068 169.182918 1.317829e+02 6.911367e+00 4.702274e+00 COLOMBIA Mtto. Correctivo yhat_reconc_p12act 888.0 4.418766e+02 6.635099e+02 235.640478 81.244682 68.352682 1.139509e+02 8.392020e-01 7.351206e-01 COLOMBIA Mtto. Correctivo yhat_reconc_p10 888.0 4.425735e+02 6.603662e+02 240.035774 82.078806 68.460488 1.256100e+02 8.416215e-01 7.362800e-01 COLOMBIA Mtto. Correctivo yhat_reconc_p11 888.0 4.426605e+02 6.636764e+02 235.460520 81.253604 68.473953 1.124105e+02 8.423218e-01 7.364248e-01 COLOMBIA Mtto. Correctivo yhat_reconc_p12std 888.0 4.486512e+02 6.672469e+02 244.324060 82.815262 69.400635 1.293727e+02 9.489757e-01 7.463911e-01 COLOMBIA Obras yhat_reconc_p10 168.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Obras yhat_reconc_p11 168.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Obras yhat_reconc_p12act 168.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Obras yhat_reconc_p12std 168.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COLOMBIA Servicios Ctto. yhat_reconc_p11 24.0 9.661344e+02 1.232601e+03 36.497305 36.647099 36.101647 -7.581767e+02 4.191610e+00 1.324417e+00 COLOMBIA Servicios Ctto. yhat_reconc_p10 24.0 9.665327e+02 1.230126e+03 36.848030 36.886379 36.116531 -7.533263e+02 4.273413e+00 1.324963e+00 COLOMBIA Servicios Ctto. yhat_reconc_p12act 24.0 1.160137e+03 1.291332e+03 68.103600 122.117872 43.350966 -1.160137e+03 1.081178e+01 1.590364e+00 COLOMBIA Servicios Ctto. yhat_reconc_p12std 24.0 1.161847e+03 1.335397e+03 68.096060 73.199806 43.414867 -8.031317e+02 1.087115e+01 1.592708e+00 COLOMBIA Servicios Extra yhat_reconc_p12act 876.0 9.002190e+01 1.610567e+02 147.972778 117.309055 96.121866 2.956006e+00 8.691726e-01 8.489199e-01 COLOMBIA Servicios Extra yhat_reconc_p11 876.0 9.033841e+01 1.613576e+02 147.871422 117.464561 96.459824 3.199186e+00 8.876542e-01 8.519047e-01 COLOMBIA Servicios Extra yhat_reconc_p10 876.0 9.045936e+01 1.613609e+02 148.303465 118.538624 96.588976 3.734279e+00 8.901743e-01 8.530453e-01 COLOMBIA Servicios Extra yhat_reconc_p12std 876.0 9.250879e+01 1.674328e+02 147.949851 119.118394 98.777277 5.428950e+00 1.008171e+00 8.723717e-01 COLOMBIA Suministros yhat_reconc_p10 888.0 2.843154e+03 5.784948e+03 30.676294 40.731907 33.771866 -1.895771e+03 3.327711e+00 2.613579e+00 COLOMBIA Suministros yhat_reconc_p11 888.0 4.235636e+03 1.032117e+04 33.751415 43.944223 50.312196 -3.569215e+03 4.380259e+00 3.893623e+00 COLOMBIA Suministros yhat_reconc_p12act 888.0 4.250387e+03 1.033687e+04 33.849609 44.212382 50.487414 -3.596395e+03 4.399560e+00 3.907183e+00 COLOMBIA Suministros yhat_reconc_p12std 888.0 4.502428e+03 1.051161e+04 40.019443 55.786519 53.481241 -4.020510e+03 4.838715e+00 4.138873e+00 COSTA RICA Mtto. Contratos yhat_reconc_p10 108.0 2.090395e+02 3.412823e+02 94.437230 70.127574 64.857570 1.464631e+02 8.916542e-01 9.303899e-01 COSTA RICA Mtto. Contratos yhat_reconc_p11 108.0 2.106987e+02 3.396254e+02 93.331942 75.443614 65.372352 8.655281e+01 8.874159e-01 9.377745e-01 COSTA RICA Mtto. Contratos yhat_reconc_p12std 108.0 2.212114e+02 3.489684e+02 101.513187 74.801343 68.634092 1.077226e+02 9.381215e-01 9.845645e-01 COSTA RICA Mtto. Contratos yhat_reconc_p12act 108.0 2.260652e+02 3.537979e+02 105.011372 77.110730 70.140049 1.134218e+02 9.653356e-01 1.006168e+00 COSTA RICA Mtto. Correctivo yhat_reconc_p11 120.0 2.650117e+02 3.930480e+02 124.198586 85.617393 59.727139 -8.515727e-01 7.601019e-01 7.393726e-01 COSTA RICA Mtto. Correctivo yhat_reconc_p12act 120.0 2.652603e+02 3.919777e+02 124.907970 85.409398 59.783171 2.872679e+00 7.635030e-01 7.400662e-01 COSTA RICA Mtto. Correctivo yhat_reconc_p10 120.0 2.666940e+02 3.914438e+02 124.989253 85.667053 60.106297 4.591948e+00 7.632649e-01 7.440663e-01 COSTA RICA Mtto. Correctivo yhat_reconc_p12std 120.0 2.689895e+02 3.927227e+02 129.010945 85.735395 60.623653 1.227309e+01 7.795721e-01 7.504707e-01 COSTA RICA Obras yhat_reconc_p10 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COSTA RICA Obras yhat_reconc_p11 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COSTA RICA Obras yhat_reconc_p12act 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COSTA RICA Obras yhat_reconc_p12std 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 COSTA RICA Servicios Ctto. yhat_reconc_p11 108.0 5.561821e+01 9.700808e+01 13.670388 25.816044 12.457179 -2.438456e-01 5.796498e-01 5.185397e-01 COSTA RICA Servicios Ctto. yhat_reconc_p12act 108.0 5.562710e+01 9.704542e+01 13.669048 25.811259 12.459171 -2.095185e-01 5.797147e-01 5.186226e-01 COSTA RICA Servicios Ctto. yhat_reconc_p12std 108.0 5.563211e+01 9.707430e+01 13.667388 25.806742 12.460293 -1.797925e-01 5.797412e-01 5.186693e-01 COSTA RICA Servicios Ctto. yhat_reconc_p10 108.0 5.568180e+01 9.776440e+01 13.622305 25.594305 12.471422 1.522387e+00 5.820670e-01 5.191325e-01 COSTA RICA Servicios Extra yhat_reconc_p11 120.0 1.370177e+02 2.060673e+02 39.416173 48.659177 27.250967 5.633716e+01 5.943168e-01 4.802084e-01 COSTA RICA Servicios Extra yhat_reconc_p12std 120.0 1.370249e+02 2.060720e+02 39.409447 49.891631 27.252391 5.638540e+01 5.943273e-01 4.802335e-01 COSTA RICA Servicios Extra yhat_reconc_p12act 120.0 1.370270e+02 2.060730e+02 39.408611 48.627366 27.252811 5.639767e+01 5.943199e-01 4.802409e-01 COSTA RICA Servicios Extra yhat_reconc_p10 120.0 1.373846e+02 2.067580e+02 39.458854 49.913652 27.323933 5.699699e+01 5.948116e-01 4.814942e-01 COSTA RICA Suministros yhat_reconc_p12std 120.0 6.465353e+03 7.561012e+03 100.000000 200.000000 833.825244 4.914583e+03 5.850810e-01 1.860343e-01 COSTA RICA Suministros yhat_reconc_p12act 120.0 6.472184e+03 7.567896e+03 100.000000 200.000000 834.706304 4.921415e+03 5.856992e-01 1.862309e-01 COSTA RICA Suministros yhat_reconc_p11 120.0 6.472407e+03 7.567938e+03 100.000000 200.000000 834.735038 4.921638e+03 5.857194e-01 1.862373e-01 COSTA RICA Suministros yhat_reconc_p10 120.0 7.208459e+03 8.563702e+03 99.522594 199.809945 929.662357 5.665093e+03 6.523283e-01 2.074165e-01 ESPAÑA Eficiencia Energética yhat_reconc_p10 276.0 1.996341e+02 6.681947e+02 75.288505 168.007316 36.542168 -1.211499e+02 3.333023e+09 6.415581e-01 ESPAÑA Eficiencia Energética yhat_reconc_p12act 276.0 5.274124e+02 1.303192e+03 116.144669 171.210057 96.540605 -2.305626e+02 1.276658e+10 1.694930e+00 ESPAÑA Eficiencia Energética yhat_reconc_p11 276.0 5.395867e+02 1.322858e+03 116.212032 171.791678 98.769050 -2.278881e+02 1.276658e+10 1.734054e+00 ESPAÑA Eficiencia Energética yhat_reconc_p12std 276.0 7.606144e+02 1.349497e+03 154.009210 183.948998 139.227229 5.263372e+01 2.712789e+10 2.444364e+00 ESPAÑA Licencias yhat_reconc_p10 2712.0 4.820633e+01 1.508958e+02 94.807736 195.760911 179.215517 3.510925e+00 3.428023e+08 4.643468e-01 ESPAÑA Licencias yhat_reconc_p11 2712.0 5.052590e+01 1.711367e+02 102.712488 194.752832 187.838898 1.268480e+00 3.328306e+08 4.866900e-01 ESPAÑA Licencias yhat_reconc_p12act 2712.0 6.354883e+01 2.166021e+02 97.970115 194.127343 236.253932 1.520108e+01 3.328306e+08 6.121332e-01 ESPAÑA Licencias yhat_reconc_p12std 2712.0 2.250443e+02 1.845398e+03 257.492276 196.864184 836.641831 1.799589e+02 4.064774e+08 2.167736e+00 ESPAÑA Mtto. Contratos yhat_reconc_p10 3108.0 1.123645e+02 2.668509e+02 77.608144 150.975188 61.405148 1.600283e+01 3.271766e+00 1.760724e+00 ESPAÑA Mtto. Contratos yhat_reconc_p11 3108.0 1.200335e+02 2.993945e+02 82.956805 144.738313 65.596143 -1.496156e+01 3.311149e+00 1.880896e+00 ESPAÑA Mtto. Contratos yhat_reconc_p12std 3108.0 6.399644e+02 2.698899e+03 351.693902 154.923618 349.728950 4.955256e+02 1.707786e+02 1.002809e+01 ESPAÑA Mtto. Contratos yhat_reconc_p12act 3108.0 7.198411e+02 3.581909e+03 463.038823 152.955502 393.380134 5.662103e+02 2.093438e+02 1.127974e+01 ESPAÑA Mtto. Correctivo yhat_reconc_p10 3168.0 3.910281e+02 8.215922e+02 138.284816 134.550139 85.300348 -8.600146e+01 1.766795e+00 1.284041e+00 ESPAÑA Mtto. Correctivo yhat_reconc_p11 3168.0 4.158931e+02 8.983615e+02 146.470494 131.579565 90.724478 -9.069739e+01 1.770968e+00 1.365692e+00 ESPAÑA Mtto. Correctivo yhat_reconc_p12std 3168.0 4.997329e+02 1.161020e+03 164.324931 132.554205 109.013611 5.520675e+00 5.795190e+00 1.641001e+00 ESPAÑA Mtto. Correctivo yhat_reconc_p12act 3168.0 5.252093e+02 1.611736e+03 160.555164 135.609618 114.571128 -2.306559e+01 2.064205e+00 1.724660e+00 ESPAÑA Obras yhat_reconc_p10 816.0 3.003415e+02 7.615337e+02 76.988315 143.376683 32.942737 -4.584905e+01 9.820125e-01 3.992246e-01 ESPAÑA Obras yhat_reconc_p11 816.0 7.554801e+02 3.223828e+03 226.693472 139.317273 82.864275 -2.658008e+02 1.284856e+00 1.004211e+00 ESPAÑA Obras yhat_reconc_p12act 816.0 1.036250e+03 3.405190e+03 391.347413 140.066773 113.660304 3.611894e+01 8.453167e+00 1.377420e+00 ESPAÑA Obras yhat_reconc_p12std 816.0 2.355086e+03 9.168397e+03 1163.678566 141.649490 258.315902 1.297989e+03 5.702347e+07 3.130464e+00 ESPAÑA Servicios Ctto. yhat_reconc_p10 2796.0 1.517697e+02 5.145702e+02 57.092712 117.107335 14.821149 -1.171388e+00 3.851354e+06 7.371908e-01 ESPAÑA Servicios Ctto. yhat_reconc_p11 2796.0 1.927070e+02 7.187720e+02 51.877846 114.219389 18.818912 -4.715020e+01 3.770367e+06 9.360359e-01 ESPAÑA Servicios Ctto. yhat_reconc_p12act 2796.0 6.673740e+02 3.351419e+03 389.190483 119.841246 65.172769 2.636087e+02 3.794877e+06 3.241636e+00 ESPAÑA Servicios Ctto. yhat_reconc_p12std 2796.0 1.009206e+03 5.717512e+03 684.446336 121.852081 98.554575 6.202412e+02 3.814621e+06 4.902017e+00 ESPAÑA Servicios Extra yhat_reconc_p11 2352.0 2.353164e+02 2.798129e+03 211.091721 178.106379 121.544532 -9.282737e+01 1.623507e+08 1.823178e+00 ESPAÑA Servicios Extra yhat_reconc_p10 2352.0 2.783898e+02 2.099504e+03 291.026203 184.968571 143.792616 8.444804e+01 1.628291e+08 2.156902e+00 ESPAÑA Servicios Extra yhat_reconc_p12std 2352.0 5.114136e+02 4.301113e+03 452.064995 188.750601 264.153004 2.059871e+02 1.652508e+08 3.962318e+00 ESPAÑA Servicios Extra yhat_reconc_p12act 2352.0 5.508455e+02 5.086561e+03 480.078512 181.208216 284.520234 2.354741e+02 1.623510e+08 4.267828e+00 ESPAÑA Suministros yhat_reconc_p10 3696.0 4.470256e+02 1.006780e+03 75.562949 50.076791 41.579036 2.755214e+02 8.407124e-01 2.875603e-01 ESPAÑA Suministros yhat_reconc_p11 3696.0 4.841622e+02 1.069207e+03 80.128235 51.055702 45.033209 3.023840e+02 8.502245e-01 3.114494e-01 ESPAÑA Suministros yhat_reconc_p12std 3696.0 5.090017e+02 1.177697e+03 78.536631 53.718040 47.343598 2.871335e+02 8.769141e-01 3.274280e-01 ESPAÑA Suministros yhat_reconc_p12act 3696.0 5.969939e+02 2.095606e+03 87.241136 52.466698 55.527988 3.820775e+02 1.435527e+00 3.840312e-01 MÉXICO Eficiencia Energética yhat_reconc_p11 24.0 3.458000e+02 1.694067e+03 4.760073 3.029561 6.399805 3.458000e+02 8.692598e-02 1.202973e-01 MÉXICO Eficiencia Energética yhat_reconc_p12act 24.0 3.498822e+02 1.696260e+03 4.816266 3.082074 6.475356 3.498822e+02 8.795215e-02 1.217174e-01 MÉXICO Eficiencia Energética yhat_reconc_p12std 24.0 3.509407e+02 1.696161e+03 4.830837 3.096801 6.494945 3.509407e+02 8.821823e-02 1.220857e-01 MÉXICO Eficiencia Energética yhat_reconc_p10 24.0 1.033264e+03 2.378422e+03 14.223279 10.870121 19.122861 8.930316e+02 2.597381e-01 3.594529e-01 MÉXICO Licencias yhat_reconc_p11 360.0 9.012045e+01 4.603941e+02 90.033625 190.605922 145.986827 -2.212986e+01 3.991086e+08 1.331120e+00 MÉXICO Licencias yhat_reconc_p10 360.0 1.082300e+02 4.225867e+02 87.570567 193.906442 175.322657 2.709212e+00 4.080004e+08 1.598607e+00 MÉXICO Licencias yhat_reconc_p12act 360.0 1.595556e+02 8.028950e+02 221.194146 189.928600 258.465407 5.287523e+01 3.991087e+08 2.356710e+00 MÉXICO Licencias yhat_reconc_p12std 360.0 2.578505e+03 1.295259e+04 7237.379322 194.384054 4176.939912 2.481404e+03 7.415318e+08 3.808570e+01 MÉXICO Mtto. Contratos yhat_reconc_p11 360.0 2.388590e+02 4.744705e+02 11.120825 18.081346 9.316478 -2.252095e+02 1.118095e+00 5.913739e-01 MÉXICO Mtto. Contratos yhat_reconc_p10 360.0 2.438610e+02 5.864598e+02 10.856864 17.365888 9.511579 -1.581693e+02 1.131542e+00 6.037582e-01 MÉXICO Mtto. Contratos yhat_reconc_p12std 360.0 3.315708e+02 6.556694e+02 91.375547 21.213760 12.932618 -1.022876e+02 1.940925e+01 8.209125e-01 MÉXICO Mtto. Contratos yhat_reconc_p12act 360.0 3.659308e+02 7.773484e+02 119.598913 21.249635 14.272798 -6.022180e+01 2.600152e+01 9.059819e-01 MÉXICO Mtto. Correctivo yhat_reconc_p11 384.0 1.293459e+03 2.112047e+03 180.772482 70.704551 57.929066 1.146384e+01 9.305130e-01 7.995337e-01 MÉXICO Mtto. Correctivo yhat_reconc_p12act 384.0 1.294224e+03 2.111691e+03 181.781608 70.609185 57.963341 1.716024e+01 9.323254e-01 8.000068e-01 MÉXICO Mtto. Correctivo yhat_reconc_p12std 384.0 1.297287e+03 2.111066e+03 184.239828 70.480368 58.100519 3.199768e+01 9.369424e-01 8.019001e-01 MÉXICO Mtto. Correctivo yhat_reconc_p10 384.0 1.377875e+03 2.157477e+03 205.430086 72.781081 61.709759 1.844418e+02 9.788445e-01 8.517146e-01 MÉXICO Obras yhat_reconc_p10 48.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 MÉXICO Obras yhat_reconc_p11 48.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 MÉXICO Obras yhat_reconc_p12act 48.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 MÉXICO Obras yhat_reconc_p12std 48.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 MÉXICO Servicios Ctto. yhat_reconc_p12std 372.0 1.205576e+03 1.583236e+03 17.712200 17.659197 15.606150 -1.153989e+03 8.134833e-01 7.209804e-01 MÉXICO Servicios Ctto. yhat_reconc_p12act 372.0 1.205609e+03 1.583265e+03 17.712880 17.660159 15.606577 -1.154024e+03 8.135409e-01 7.210001e-01 MÉXICO Servicios Ctto. yhat_reconc_p11 372.0 1.205646e+03 1.583297e+03 17.713571 17.661225 15.607055 -1.154064e+03 8.136060e-01 7.210222e-01 MÉXICO Servicios Ctto. yhat_reconc_p10 372.0 1.256766e+03 1.659155e+03 19.792533 17.041092 16.268811 -9.196107e+02 8.448010e-01 7.515943e-01 MÉXICO Servicios Extra yhat_reconc_p10 384.0 7.783359e+02 1.449973e+03 85.333999 122.428547 67.510553 -3.127893e+02 1.754318e+00 1.408148e+00 MÉXICO Servicios Extra yhat_reconc_p12act 384.0 7.850293e+02 1.467116e+03 84.414310 117.194154 68.091124 -3.446144e+02 1.765017e+00 1.420258e+00 MÉXICO Servicios Extra yhat_reconc_p12std 384.0 7.850439e+02 1.467166e+03 84.440099 124.361213 68.092390 -3.447651e+02 1.764662e+00 1.420284e+00 MÉXICO Servicios Extra yhat_reconc_p11 384.0 7.850917e+02 1.467387e+03 84.584961 117.556604 68.096532 -3.454633e+02 1.764436e+00 1.420371e+00 MÉXICO Suministros yhat_reconc_p12std 372.0 2.523998e+03 4.826934e+03 43.728503 28.266964 18.908743 3.653785e+02 1.095180e+00 9.681665e-01 MÉXICO Suministros yhat_reconc_p12act 372.0 2.565097e+03 4.877628e+03 47.265516 28.407935 19.216637 4.390553e+02 1.117792e+00 9.839313e-01 MÉXICO Suministros yhat_reconc_p11 372.0 2.566828e+03 4.880635e+03 47.527178 28.405516 19.229606 4.437887e+02 1.118909e+00 9.845954e-01 MÉXICO Suministros yhat_reconc_p10 372.0 2.707181e+03 5.260576e+03 48.702784 28.682353 20.281070 8.313456e+02 1.154554e+00 1.038432e+00 PANAMÁ Eficiencia Energética yhat_reconc_p11 420.0 6.635516e+01 1.312892e+02 28.421826 24.684063 27.350951 2.456932e+01 3.757856e-01 2.921270e-01 PANAMÁ Eficiencia Energética yhat_reconc_p10 420.0 6.852578e+01 1.302864e+02 29.214597 25.154686 28.245660 2.716541e+01 3.831320e-01 3.016831e-01 PANAMÁ Eficiencia Energética yhat_reconc_p12act 420.0 1.090331e+02 1.846150e+02 54.160348 33.046208 44.942390 9.180045e+01 9.168524e-01 4.800156e-01 PANAMÁ Eficiencia Energética yhat_reconc_p12std 420.0 1.250056e+02 2.145591e+02 63.139800 35.547533 51.526096 1.092329e+02 1.120453e+00 5.503342e-01 PANAMÁ Mtto. Contratos yhat_reconc_p10 456.0 8.032613e+01 2.076829e+02 22.628851 31.161390 24.894311 -4.356891e+01 2.038193e+00 7.672442e-01 PANAMÁ Mtto. Contratos yhat_reconc_p11 456.0 8.440131e+01 2.715856e+02 20.648333 31.974975 26.157272 -6.956163e+01 2.046855e+00 8.061687e-01 PANAMÁ Mtto. Contratos yhat_reconc_p12std 456.0 8.453532e+02 2.705351e+03 1039.718026 34.354192 261.988056 7.101036e+02 2.644371e+02 8.074488e+00 PANAMÁ Mtto. Contratos yhat_reconc_p12act 456.0 1.109494e+03 3.635373e+03 1401.081320 34.638977 343.849452 9.767355e+02 3.553386e+02 1.059746e+01 PANAMÁ Mtto. Correctivo yhat_reconc_p10 480.0 8.345387e+02 1.156740e+03 205.066659 74.600907 59.636311 9.717648e+01 1.373729e+00 6.800536e-01 PANAMÁ Mtto. Correctivo yhat_reconc_p11 480.0 8.360584e+02 1.155348e+03 202.009318 75.083126 59.744913 5.415855e+01 1.396133e+00 6.812920e-01 PANAMÁ Mtto. Correctivo yhat_reconc_p12act 480.0 9.161377e+02 1.356590e+03 255.329328 75.209723 65.467392 1.557578e+02 4.357639e+00 7.465474e-01 PANAMÁ Mtto. Correctivo yhat_reconc_p12std 480.0 1.089764e+03 2.037241e+03 428.940679 75.276363 77.874802 3.588873e+02 1.080204e+01 8.880334e-01 PANAMÁ Obras yhat_reconc_p10 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PANAMÁ Obras yhat_reconc_p11 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PANAMÁ Obras yhat_reconc_p12act 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PANAMÁ Obras yhat_reconc_p12std 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PANAMÁ Servicios Ctto. yhat_reconc_p12std 444.0 5.999912e+02 1.366406e+03 122.053990 17.330603 11.696189 1.203363e+02 7.214208e-01 4.738209e-01 PANAMÁ Servicios Ctto. yhat_reconc_p12act 444.0 6.000564e+02 1.366429e+03 122.052743 17.332683 11.697459 1.202428e+02 7.221703e-01 4.738724e-01 PANAMÁ Servicios Ctto. yhat_reconc_p11 444.0 6.001130e+02 1.366420e+03 122.044181 17.334554 11.698563 1.201381e+02 7.228624e-01 4.739171e-01 PANAMÁ Servicios Ctto. yhat_reconc_p10 444.0 1.016816e+03 1.726027e+03 150.303417 21.607832 19.821752 8.172884e+02 1.336917e+00 8.029932e-01 PANAMÁ Servicios Extra yhat_reconc_p10 480.0 4.602213e+02 1.158791e+03 92.744079 145.318572 109.103449 -1.395756e+02 2.421084e+00 1.690418e+00 PANAMÁ Servicios Extra yhat_reconc_p12act 480.0 4.610241e+02 1.188274e+03 93.674997 141.469584 109.293758 -1.665621e+02 2.425195e+00 1.693367e+00 PANAMÁ Servicios Extra yhat_reconc_p12std 480.0 4.611320e+02 1.188580e+03 93.609225 150.963355 109.319341 -1.666156e+02 2.426508e+00 1.693763e+00 PANAMÁ Servicios Extra yhat_reconc_p11 480.0 4.614420e+02 1.190097e+03 93.726149 141.680637 109.392817 -1.679184e+02 2.427938e+00 1.694902e+00 PANAMÁ Suministros yhat_reconc_p12act 444.0 1.899531e+03 3.382916e+03 14.145671 17.269090 14.124891 -8.224103e+02 8.878280e-01 7.773480e-01 PANAMÁ Suministros yhat_reconc_p11 444.0 1.900735e+03 3.383862e+03 14.161453 17.279268 14.133845 -8.200811e+02 8.897528e-01 7.778408e-01 PANAMÁ Suministros yhat_reconc_p12std 444.0 1.910780e+03 3.398735e+03 14.182591 17.370231 14.208537 -8.592755e+02 8.971305e-01 7.819514e-01 PANAMÁ Suministros yhat_reconc_p10 444.0 2.085373e+03 4.598738e+03 14.803782 17.600825 15.506813 -6.496110e+02 9.346436e-01 8.534006e-01 PERÚ Eficiencia Energética yhat_reconc_p11 120.0 2.232143e+01 1.546474e+02 100.000000 200.000000 100.000000 -2.232143e+01 2.232143e+09 8.928571e+08 PERÚ Eficiencia Energética yhat_reconc_p12act 120.0 2.232143e+01 1.546474e+02 100.000000 200.000000 100.000000 -2.232143e+01 2.232143e+09 8.928571e+08 PERÚ Eficiencia Energética yhat_reconc_p10 120.0 2.694043e+01 1.534520e+02 98.822603 199.870700 120.693127 -1.717680e+01 2.694043e+09 1.077617e+09 PERÚ Eficiencia Energética yhat_reconc_p12std 120.0 3.467278e+02 3.556328e+02 64.693662 197.825531 1553.340390 3.178467e+02 3.467278e+10 1.386911e+10 PERÚ Licencias yhat_reconc_p10 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PERÚ Licencias yhat_reconc_p11 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PERÚ Licencias yhat_reconc_p12act 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PERÚ Licencias yhat_reconc_p12std 12.0 NaN NaN NaN NaN NaN NaN NaN 0.000000e+00 PERÚ Mtto. Contratos yhat_reconc_p11 240.0 1.342286e+02 3.048659e+02 94.041481 164.135807 108.552129 -4.726695e+01 8.262865e-01 9.558674e-01 PERÚ Mtto. Contratos yhat_reconc_p10 240.0 1.393512e+02 2.822793e+02 105.181445 177.115518 112.694842 3.200905e+00 8.899263e-01 9.923465e-01 PERÚ Mtto. Contratos yhat_reconc_p12act 240.0 1.399238e+02 3.013646e+02 96.556927 161.118637 113.157858 -2.705981e+01 8.674427e-01 9.964236e-01 PERÚ Mtto. Contratos yhat_reconc_p12std 240.0 1.455293e+02 2.971595e+02 93.408127 178.724346 117.691088 -1.981663e+01 9.309128e-01 1.036341e+00 PERÚ Mtto. Correctivo yhat_reconc_p10 264.0 5.673446e+02 9.001501e+02 173.831241 84.490067 61.199668 -1.228779e+02 6.714489e+08 9.958465e-01 PERÚ Mtto. Correctivo yhat_reconc_p11 264.0 5.729770e+02 9.202647e+02 168.351561 80.305813 61.807244 -1.497527e+02 6.737184e+08 1.005733e+00 PERÚ Mtto. Correctivo yhat_reconc_p12act 264.0 5.731183e+02 9.161022e+02 171.487454 80.006859 61.822482 -1.409987e+02 6.737184e+08 1.005981e+00 PERÚ Mtto. Correctivo yhat_reconc_p12std 264.0 5.731815e+02 9.060736e+02 179.241892 86.358858 61.829299 -1.170435e+02 7.804287e+08 1.006092e+00 PERÚ Obras yhat_reconc_p11 24.0 0.000000e+00 0.000000e+00 NaN NaN NaN 0.000000e+00 0.000000e+00 0.000000e+00 PERÚ Obras yhat_reconc_p12act 24.0 0.000000e+00 0.000000e+00 NaN NaN NaN 0.000000e+00 0.000000e+00 0.000000e+00 PERÚ Obras yhat_reconc_p10 24.0 5.254146e-11 9.816350e-11 NaN 200.000000 NaN 5.254146e-11 5.254146e-03 5.254146e-03 PERÚ Obras yhat_reconc_p12std 24.0 4.419319e+00 1.530897e+01 NaN 200.000000 NaN 4.419319e+00 4.419319e+08 4.419319e+08 PERÚ Servicios Ctto. yhat_reconc_p12std 252.0 1.801628e+02 5.115126e+02 48.512657 95.652572 65.568485 -1.428097e+02 1.720110e+00 2.707069e+00 PERÚ Servicios Ctto. yhat_reconc_p12act 252.0 1.802323e+02 5.118008e+02 48.540786 90.511150 65.593751 -1.429818e+02 1.720164e+00 2.708112e+00 PERÚ Servicios Ctto. yhat_reconc_p11 252.0 1.803013e+02 5.121327e+02 48.554952 90.564413 65.618869 -1.431665e+02 1.720313e+00 2.709149e+00 PERÚ Servicios Ctto. yhat_reconc_p10 252.0 1.803374e+02 5.109301e+02 49.342854 94.144298 65.632021 -1.382955e+02 1.721000e+00 2.709692e+00 PERÚ Servicios Extra yhat_reconc_p11 240.0 7.825841e+01 1.661053e+02 92.468819 117.805026 109.410023 -9.556473e-01 3.642955e-01 3.735150e-01 PERÚ Servicios Extra yhat_reconc_p12act 240.0 7.826297e+01 1.661090e+02 92.476617 117.798348 109.416405 -9.406693e-01 3.643230e-01 3.735368e-01 PERÚ Servicios Extra yhat_reconc_p12std 240.0 7.826441e+01 1.661048e+02 92.472918 129.440318 109.418423 -9.382836e-01 3.643385e-01 3.735437e-01 PERÚ Servicios Extra yhat_reconc_p10 240.0 7.846058e+01 1.660491e+02 92.863194 128.097864 109.692679 -3.473198e-01 3.651818e-01 3.744800e-01 PERÚ Suministros yhat_reconc_p12std 240.0 5.250505e+02 1.106467e+03 8.176102 11.287446 10.386624 1.479579e+02 3.703562e-01 3.453627e-01 PERÚ Suministros yhat_reconc_p12act 240.0 5.273371e+02 1.117389e+03 8.190363 11.292858 10.431857 1.548806e+02 3.716885e-01 3.468668e-01 PERÚ Suministros yhat_reconc_p11 240.0 5.274492e+02 1.118737e+03 8.189844 11.291893 10.434074 1.553164e+02 3.717653e-01 3.469405e-01 PERÚ Suministros yhat_reconc_p10 240.0 5.481222e+02 1.220131e+03 8.306354 11.396462 10.843031 1.716174e+02 3.847903e-01 3.605386e-01 REPÚBLICA DOMINICANA Mtto. Contratos yhat_reconc_p11 60.0 1.908080e+01 2.427800e+01 11.282162 45.233373 17.716734 1.908080e+01 8.461538e+08 1.719753e+00 REPÚBLICA DOMINICANA Mtto. Contratos yhat_reconc_p10 60.0 1.913037e+01 2.431592e+01 11.329943 45.270453 17.762759 1.913037e+01 8.481249e+08 1.724220e+00 REPÚBLICA DOMINICANA Mtto. Contratos yhat_reconc_p12std 60.0 4.244583e+01 4.509879e+01 37.881797 61.580149 39.411422 4.244583e+01 1.327586e+09 3.825643e+00 REPÚBLICA DOMINICANA Mtto. Contratos yhat_reconc_p12act 60.0 5.044228e+01 5.273298e+01 47.072963 66.194538 46.836215 5.044228e+01 1.492351e+09 4.546363e+00 REPÚBLICA DOMINICANA Mtto. Correctivo yhat_reconc_p11 60.0 3.030747e+03 4.480070e+03 227.590802 71.267040 81.401150 2.413365e+03 9.180218e-01 1.036302e+00 REPÚBLICA DOMINICANA Mtto. Correctivo yhat_reconc_p12act 60.0 3.032668e+03 4.484630e+03 227.935220 71.276482 81.452756 2.418622e+03 9.185722e-01 1.036959e+00 REPÚBLICA DOMINICANA Mtto. Correctivo yhat_reconc_p12std 60.0 3.042176e+03 4.495613e+03 228.823392 71.397801 81.708134 2.431892e+03 9.216547e-01 1.040210e+00 REPÚBLICA DOMINICANA Mtto. Correctivo yhat_reconc_p10 60.0 3.353584e+03 5.002303e+03 238.655315 73.090788 90.072070 2.750004e+03 9.874069e-01 1.146690e+00 REPÚBLICA DOMINICANA Servicios Ctto. yhat_reconc_p11 60.0 3.830549e+02 7.529380e+02 8.398192 44.652899 8.717037 -1.124331e+02 4.089023e+00 4.422237e-01 REPÚBLICA DOMINICANA Servicios Ctto. yhat_reconc_p12act 60.0 3.855593e+02 7.545538e+02 8.308262 44.575566 8.774028 -1.092769e+02 4.204374e+00 4.451149e-01 REPÚBLICA DOMINICANA Servicios Ctto. yhat_reconc_p12std 60.0 3.878831e+02 7.560343e+02 8.252380 44.529030 8.826911 -1.065438e+02 4.311360e+00 4.477977e-01 REPÚBLICA DOMINICANA Servicios Ctto. yhat_reconc_p10 60.0 4.594580e+02 7.956928e+02 10.988926 45.238182 10.455713 1.036926e+02 4.248579e+00 5.304284e-01 REPÚBLICA DOMINICANA Servicios Extra yhat_reconc_p11 60.0 1.886345e+02 3.647939e+02 157.927299 175.538037 199.168880 9.116547e+01 1.128140e+00 8.307934e-01 REPÚBLICA DOMINICANA Servicios Extra yhat_reconc_p12act 60.0 1.887183e+02 3.648411e+02 157.949094 175.526483 199.257384 9.126794e+01 1.130918e+00 8.311626e-01 REPÚBLICA DOMINICANA Servicios Extra yhat_reconc_p12std 60.0 1.887233e+02 3.648282e+02 157.904547 182.626676 199.262639 9.129053e+01 1.130825e+00 8.311845e-01 REPÚBLICA DOMINICANA Servicios Extra yhat_reconc_p10 60.0 1.908413e+02 3.671794e+02 159.116394 181.707282 201.498950 9.400661e+01 1.136945e+00 8.405128e-01 REPÚBLICA DOMINICANA Suministros yhat_reconc_p12std 60.0 7.747158e+03 1.331222e+04 31.506105 89.599952 57.434841 3.441033e+03 1.320262e+00 1.317660e+00 REPÚBLICA DOMINICANA Suministros yhat_reconc_p12act 60.0 7.793528e+03 1.333558e+04 32.076883 89.805474 57.778613 3.501500e+03 1.335461e+00 1.325546e+00 REPÚBLICA DOMINICANA Suministros yhat_reconc_p11 60.0 7.797124e+03 1.333897e+04 32.106415 89.818076 57.805277 3.505306e+03 1.336526e+00 1.326158e+00 REPÚBLICA DOMINICANA Suministros yhat_reconc_p10 60.0 1.231737e+04 2.624850e+04 33.930902 91.402644 91.316865 6.660176e+03 1.774957e+00 2.094975e+00
Interpretación¶
Lo que observamos en las tablas:
- A nivel global:
P10 es el más sólido en MAE, RMSE y WAPE y además tiene bias casi nulo (-3.8).
P11 tiene MAPE/sMAPE algo mejores que P10, pero peor RMSE/WAPE y sesgo negativo (tiende a infra-predecir: -122.8).
P12 (std/act) queda claramente por detrás en todas las métricas agregadas.
Recomendación global: si hay que elegir uno, P10.
- A nivel país:
Colombia: P10 gana con margen; P11/P12 empeoran.
Costa Rica: todos muy parecidos; ligera ventaja P11/P12 sobre P10.
España: P10 y P11 muy bien; P12 empeora fuerte.
México: P11 mejor; P10 cerca; P12std flojo.
Panamá: P11 mejor; P10 segundo; P12 peor.
Perú: diferencias pequeñas; P11 algo mejor.
R. Dominicana: ningún modelo brilla; hay problema estructural (datos/ruido/escala).
Recomendación por país: P10 para Colombia y España; P11 para México, Panamá y Perú; Costa Rica indiferente; revisar RD.
- A nviel tipo de coste FM:
Eficiencia Energética: P10 mejor; P11 cerca; P12 peor.
Licencias: P10 ≈ P11 muy bien; P12std catastrófico.
Mtto. Contratos / Correctivo: P10≈P11; P12 empeora.
Obras: P10 gana; P11/P12 mucho peor (std especialmente mal).
Servicios Ctto. / Extra: P11 suele ser el mejor; P10 segundo; P12 peor.
Suministros: P10 mejor; P11 algo peor; P12 peor.
Recomendación por tpo de coste: P10 por defecto; P11 solo en Servicios Ctto./Extra y en países donde ya vimos que funciona mejor.
Observaciones sobre la calidad de las métricas:
MAPE/sMAPE pueden distorsionarse cuando hay reales cercanos a 0 (ver 100% o > 100% en varios cruces). Para decidir, debemos priorizar MAE/WAPE y el sesgo.
Se observan valores raros de MASE en el resumen global (muy grandes). En los cortes por país/coste salen razonables, así que probablemente podemos deducir que:
hay pares con denominador estacional casi cero (series planas/escasas) que inflan el promedio, o
el fallback del denominador (ε) mete ruido.
Por consiguiente, si queremos usar MASE de forma robusta, deberíamos filtrar pares con MASE_scale
< umbral o reportar MASE solo donde la escala sea fiable, y quedarnos con WASE como agregado.
Acciones prácticas que podríamos implementar:
Criterio para decidir el modelo por segmento:
País: P10 (CO/ES), P11 (MX/PA/PE).
Tipo de Coste: usar P10, excepto Servicios Ctto./Extra donde P11 suele ganar.
Revisar P12: especialmente std en Obras, Licencias (ES/MX), Contratos (ES/PA) -> revisar anclas/factores y límites (clip) porque está sobrealzando.
RD: auditaremos datos (outliers, missing, nivel base≈0); quizá debamos evaluar otro enfoque de modelado o reglas.
Métricas: para informes ejecutivos, priorizaremos con MAE, WAPE y Bias; dejaremos MAPE/sMAPE solo como referencia con una aclaración según hayamos actuado con su robustez.
Guardamos en HTML¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_estrategia_3_paso_de_1_a_12_con_metrica.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 60 image(s). [NbConvertApp] Writing 9085411 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_estrategia_3_paso_de_1_a_12_con_metrica.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_estrategia_3_paso_de_1_a_12_con_metrica.html Existe: True
Paso 13 - Paquete de entrega, fichero de predicciones y “hoja de ruta”¶
Con este paso pretendemos cerrar el primer entregable del proyecto, con un entregable final pulido, entendible, trazable y que permite insertarse en la aplicación web.
Vamos a armar la carpeta de proyecto + informe ejecutivo para mostrar al equipo de FM para que nos permita decidir si adoptan la solución.
El paso 13 que vamos a implementar consisten en:
- Consolidar outputs dispersos:
Hasta ahora, cada paso ha generado resultados parciales (predicciones, reconciliaciones, métricas, gráficos, logs).
El paso 13 los recopila y organiza en una carpeta final estructurada (RESULTADOS, METRICAS, FIGS, REPORTING, LOGS).
- Calcular y presentar las métricas clave de manera sintética y trazable:
Aportando las métricas trabajadas como WAPE, sMAPE, MASE1, MASE12, Cobertura.
Reportándolas a nivel global (portfolio), por FM_COST_TYPE
, por País
y también por un subconjunto estable de series fiables (Subset Estable).
Generando además listados de outliers y Top-N para auditoría rápida.
- Dar transparencia y verificabilidad:
Generaremos un checklist que comprueba que todo está en orden (predicciones completas, métricas consistentes, nombres coherentes, ausencia de negativos, cobertura mínima).
Con un LOG documentando qué ficheros se leyeron y escribieron.
Facilitar comunicación con negocio:
Redactaremos un resumen ejecutivo de una página (Markdown), con las 5 cifras clave y mensajes claros (qué tan buena es la predicción y dónde están los riesgos).
Incluyendo gráficos simples que ayuden a explicar a un comité directivo sin tener que leer tablas técnicas.
Proyectar la siguiente etapa:
Identificar 3 quick-wins inmediatos para seguir mejorando:
Uso de TSB para intermitentes.
Ajuste de ensembles/combos.
Manejo de outliers vía mod_3.
Dejar clara la hoja de ruta para el siguiente ciclo (más fuentes de datos, refinar reconciliación, integración web).
# ============================================
# PASO 13 — Paquete de entrega + Hoja de ruta
# ============================================
# En este script:
# - Preparamos el entregable "PRIMER_ENTREGABLE"
# - Leemos suavemente artefactos de 2024/2025 (sin romper si faltan)
# - Calculamos métricas/figuras/informes si hay datos
# - Generamos bundle de inferencia para la web (2024 y 2025)
# - Creamos placeholders cuando algo falte para mantener contrato estable
# - Integramos fuentes reales: dim_buildings.csv, ipc_monthly_lookup.csv e ipc_anual_diccionario.csv
# ==================
# 0) PARÁMETROS
# ==================
np.random.seed(7)
# Ruta de ENTRADA (artefactos generados por pasos previos)
RUTA_ENTRADA = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
# Ruta del ENTREGABLE (salida de este paso 13)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE"
# CSV y umbrales
CSV_SEP = ";"
ZERO_THR = 1e-9
N_TOP = 50
UMBRAL_MASE12= 1.0
UMBRAL_SMAPE = 30.0
# Variante final por defecto; degradamos si no existe
VAR_FINAL_DEF = "P12_STD" # fallback: P11 -> P10 -> BASE
# Candidatas de fecha que solemos ver
DATE_CANDS = ["FECHA","Date","DS","PERIOD","MONTH","Mes","MES"]
# ==================
# 1) CARPETAS SALIDA
# ==================
RUTA_ENTREGABLE = ruta_base_3
CARP_RES = os.path.join(RUTA_ENTREGABLE, "RESULTADOS")
CARP_MET = os.path.join(RUTA_ENTREGABLE, "METRICAS")
CARP_FIG = os.path.join(RUTA_ENTREGABLE, "FIGURAS")
CARP_INF = os.path.join(RUTA_ENTREGABLE, "INFORMES")
CARP_LOG = os.path.join(RUTA_ENTREGABLE, "REGISTROS")
CARP_BUNDLE= os.path.join(RUTA_ENTREGABLE, "PAQUETE_INFERENCIA")
for d in [RUTA_ENTREGABLE, CARP_RES, CARP_MET, CARP_FIG, CARP_INF, CARP_LOG, CARP_BUNDLE]:
os.makedirs(d, exist_ok=True)
# ============
# 2) LOGGING
# ============
LOG_PATH = os.path.join(CARP_LOG, "paso13.log")
def log13(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
log13("Iniciamos Paso 13 — Paquete de entrega + Hoja de ruta.")
# =====================
# 3) HELPERS GENERALES
# =====================
def _ensure_ms_inplace(df: pd.DataFrame, candidate_cols=DATE_CANDS):
# Normalizamos la columna de fecha si existe alguna candidata
if df is None or df.empty: return
for c in candidate_cols:
if c in df.columns:
try:
df[c] = pd.to_datetime(df[c], errors="coerce").dt.to_period("M").dt.to_timestamp()
except Exception:
pass
def _read_csv_soft(path: str, candidate_date_cols=None, sep=CSV_SEP) -> pd.DataFrame:
# Leemos un CSV sin romper; solo parseamos fechas que EXISTAN
if not os.path.exists(path):
rel = path.replace(RUTA_ENTRADA, "").lstrip("/").lstrip("\\")
log13(f"FALTA {rel}. Seguimos sin romper.")
return pd.DataFrame()
try:
header = pd.read_csv(path, sep=sep, nrows=0)
header_cols = [str(c).strip().lstrip("\ufeff") for c in header.columns]
parse_dates = None
if candidate_date_cols:
parse_dates = [c for c in candidate_date_cols if c in header_cols] or None
df = pd.read_csv(path, sep=sep, parse_dates=parse_dates)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
except Exception as e:
log13(f"ERROR leyendo {path}: {e}. Devolvemos DF vacío.")
return pd.DataFrame()
def _norm_cols(df: pd.DataFrame, variant_tag: str=None):
# Normalizamos nombres clave y columnas de predicción para cada variante
if df is None or df.empty:
# Devolvemos un df vacío con contrato mínimo y pred_col=None
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]), None
out = df.copy()
# Unificamos posibles nombres de columnas clave
colmap = {}
for c in out.columns:
cu = str(c).strip().upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap[c] = "ID_BUILDING"
elif cu in ("FM_COST_TYPE","FM_COST","FMCOST","COST_TYPE"):
colmap[c] = "FM_COST_TYPE"
elif cu in ("FECHA","DATE","DS","PERIOD","MONTH","MES"):
colmap[c] = "FECHA"
if colmap:
out = out.rename(columns=colmap)
# Deducimos columna de predicción
pred_col = None
for candidate in ["yhat_reconc","yhat_combo","yhat","YHAT","pred","PRED","Y","valor","VALOR","forecast"]:
if candidate in out.columns:
pred_col = candidate
break
if variant_tag and not pred_col:
tagged = f"yhat_reconc_{variant_tag}"
if tagged in out.columns:
pred_col = tagged
return out, pred_col
def _norm_unpack(df, variant_tag=None):
# Helper de desempaquetado a prueba de versiones antiguas
ret = _norm_cols(df, variant_tag)
if isinstance(ret, tuple) and len(ret) == 2:
return ret[0], ret[1]
return ret, None
def _ensure_contract(df: pd.DataFrame, pred_col: str, year: int) -> pd.DataFrame:
# Aseguramos contrato mínimo: ID_BUILDING, FM_COST_TYPE, FECHA, yhat
cols = ["ID_BUILDING","FM_COST_TYPE","FECHA", pred_col] if pred_col else ["ID_BUILDING","FM_COST_TYPE","FECHA"]
df2 = df.copy()[[c for c in cols if c in df.columns]] if not df.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA", "yhat"])
if not df2.empty and pred_col and pred_col != "yhat":
df2 = df2.rename(columns={pred_col: "yhat"})
if "FECHA" in df2.columns:
_ensure_ms_inplace(df2, ["FECHA"])
# filtramos al año deseado si procede
try:
df2 = df2[df2["FECHA"].dt.year.eq(year)]
except Exception:
pass
# Tipos suaves
if "ID_BUILDING" in df2.columns:
df2["ID_BUILDING"] = pd.to_numeric(df2["ID_BUILDING"], errors="coerce").astype("Int64")
if "FM_COST_TYPE" in df2.columns:
df2["FM_COST_TYPE"] = df2["FM_COST_TYPE"].astype(str).str.strip()
return df2
def _save_csv(df: pd.DataFrame, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
df.to_csv(path, sep=CSV_SEP, index=False)
log13(f"Guardado CSV: {path} ({len(df)} filas).")
def _metrics_block(df: pd.DataFrame, col_pred: str, col_real: str, scale_mase=None):
if df.empty or col_pred not in df.columns or col_real not in df.columns:
return {"MAE":np.nan,"RMSE":np.nan,"MAPE%":np.nan,"sMAPE%":np.nan,"WAPE%":np.nan,"Bias":np.nan,"MASE":np.nan,"WASE":np.nan}
y = pd.to_numeric(df[col_real], errors="coerce")
yhat = pd.to_numeric(df[col_pred], errors="coerce")
err = yhat - y
abs_e = err.abs()
mae = abs_e.mean()
rmse = float(np.sqrt((err**2).mean())) if len(err)>0 else np.nan
mape = float((100.0 * (abs_e / y.replace(0,np.nan).abs())).mean())
smape= float((100.0 * (abs_e / ((y.abs() + yhat.abs())/2.0).replace(0,np.nan))).mean())
wape = float(100.0 * abs_e.sum() / y.abs().sum()) if y.abs().sum()!=0 else np.nan
bias = err.mean()
# MASE/WASE
if scale_mase is not None and "MASE_scale" in scale_mase.columns:
df_sc = df.merge(scale_mase, on=["ID_BUILDING","FM_COST_TYPE"], how="left")
scale = pd.to_numeric(df_sc["MASE_scale"], errors="coerce").replace(0,np.nan)
mase = float((abs_e / scale).mean())
wase = float(abs_e.sum() / scale.sum()) if scale.sum() not in [0,np.nan] else np.nan
else:
mase, wase = np.nan, np.nan
return {"MAE":mae,"RMSE":rmse,"MAPE%":mape,"sMAPE%":smape,"WAPE%":wape,"Bias":bias,"MASE":mase,"WASE":wase}
def _build_mase_scale(train_df: pd.DataFrame):
# Construimos denominador MASE estacional-12 con 2021–2023 si existe
if train_df.empty or "FECHA" not in train_df.columns or "cost_float_mod" not in train_df.columns:
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","MASE_scale"])
t = train_df.copy()
_ensure_ms_inplace(t, ["FECHA"])
t = t.sort_values(["ID_BUILDING","FM_COST_TYPE","FECHA"])
t["y_lag12"] = t.groupby(["ID_BUILDING","FM_COST_TYPE"])["cost_float_mod"].shift(12)
t["abs_diff12"] = (t["cost_float_mod"] - t["y_lag12"]).abs()
sc = (t.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False)["abs_diff12"]
.mean().rename(columns={"abs_diff12":"MASE_scale"}))
sc["MASE_scale"] = pd.to_numeric(sc["MASE_scale"], errors="coerce").fillna(0.0)
sc.loc[sc["MASE_scale"]<=0, "MASE_scale"] = 1e-8
return sc
def _select_variant_availability(base_2024, p10_2024, p11_2024, p12s_2024):
# Decidimos variante final según disponibilidad de 2024
availability = {
"P12_STD": not p12s_2024.empty,
"P11": not p11_2024.empty,
"P10": not p10_2024.empty,
"BASE": not base_2024.empty
}
order = ["P12_STD","P11","P10","BASE"]
chosen = None
for v in order:
if availability[v]:
chosen = v
break
if chosen is None:
log13("AVISO: no hay ninguna variante disponible (ni BASE). El paso continuará con métricas vacías.")
else:
log13(f"Variante final seleccionada: {chosen}")
return chosen, availability
def _schema_md_report(paths_dict: dict, frames_dict: dict, out_md: str):
# Generamos un informe rápido de esquemas de entrada
lines = ["# Esquema de entradas — Paso 13\n"]
for k, p in paths_dict.items():
lines.append(f"## {k}\n")
lines.append(f"- Ruta: `{p}`")
df = frames_dict.get(k, pd.DataFrame())
if df is None or df.empty:
lines.append("- Estado: **vacío o ausente**\n")
else:
cols = ", ".join(df.columns.tolist())
n = len(df)
lines.append(f"- Filas: {n}")
lines.append(f"- Columnas: {cols}\n")
with open(out_md, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log13(f"Guardado informe de esquema: {out_md}")
# ========================
# 4) RUTAS DE ENTRADA
# ========================
# 2024
P_BASE = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_por_serie_2024.csv")
P_P10 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2024.csv")
P_P11 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2024_prod.csv")
P_P12S = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_step12.csv")
P_P12A = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_step12_activeonly.csv")
P_COMP = os.path.join(RUTA_ENTRADA, "REPORTING", "compare_recon_steps_bottom.csv")
P_REAL = os.path.join(RUTA_ENTRADA, "RESULTADOS", "test_full_2024.csv")
P_TRN = os.path.join(RUTA_ENTRADA, "RESULTADOS", "train_full_2021_2023.csv")
# Dimensiones e IPC (fuentes reales de tu entorno)
P_DIM = os.path.join(RUTA_ENTRADA, "METRICAS", "dim_buildings.csv") # legacy real
P_DIM_ES = os.path.join(RUTA_ENTRADA, "METRICAS", "dim_edificios.csv") # si existiera
P_P8 = os.path.join(RUTA_ENTRADA, "METRICAS", "paso8_metrics_2024_por_pareja.csv")
P_IPC = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_monthly_lookup.csv") # real
P_IPC_ES = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_mensual.csv") # si existiera
P_IPC25 = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_mensual_2025.csv") # si ya existiera
P_IPC_ANO = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_anual_diccionario.csv")# real anual (fallback 2025)
# 2025 (si ya existen)
P_BASE_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_base_2025.csv")
P_P11_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2025_P11.csv")
P_P12_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2025_P12STD.csv")
# ==========================
# 5) LECTURA SUAVE ENTRADAS
# ==========================
df_base = _read_csv_soft(P_BASE, candidate_date_cols=DATE_CANDS)
df_p10 = _read_csv_soft(P_P10, candidate_date_cols=DATE_CANDS)
df_p11 = _read_csv_soft(P_P11, candidate_date_cols=DATE_CANDS)
df_p12s = _read_csv_soft(P_P12S, candidate_date_cols=DATE_CANDS)
df_p12a = _read_csv_soft(P_P12A, candidate_date_cols=DATE_CANDS)
df_comp = _read_csv_soft(P_COMP, candidate_date_cols=DATE_CANDS)
df_real = _read_csv_soft(P_REAL, candidate_date_cols=DATE_CANDS)
df_train = _read_csv_soft(P_TRN, candidate_date_cols=DATE_CANDS)
# Dimensiones: preferimos españolizado si existe
df_dim = _read_csv_soft(P_DIM_ES)
if df_dim.empty:
df_dim = _read_csv_soft(P_DIM)
# IPC: preferimos españolizado si existe; si no, monthly real
df_ipc = _read_csv_soft(P_IPC_ES, candidate_date_cols=DATE_CANDS)
if df_ipc.empty:
df_ipc = _read_csv_soft(P_IPC, candidate_date_cols=DATE_CANDS)
# IPC 2025: si no existe mensual 2025, fabricamos desde el diccionario anual
df_ipc25 = _read_csv_soft(P_IPC25, candidate_date_cols=DATE_CANDS)
if df_ipc25.empty and os.path.exists(P_IPC_ANO):
anual = _read_csv_soft(P_IPC_ANO)
if not anual.empty:
cols_up = {c.upper(): c for c in anual.columns}
df_ipc25 = pd.DataFrame()
# Caso largo: PAIS, ANIO, valor
if {"PAIS","ANIO"}.issubset(set(cols_up.keys())):
c_pais = cols_up["PAIS"]; c_year = cols_up["ANIO"]
c_val = None
for k in ("IPC_YOY_PCT","IPC","IPC_ANUAL_%","YOY","VAR_INTERANUAL"):
if k in cols_up:
c_val = cols_up[k]; break
if c_val:
tmp = anual[[c_pais, c_year, c_val]].copy()
tmp.columns = ["PAIS","ANIO","IPC_YOY_PCT"]
pref_year = 2025 if (tmp["ANIO"]==2025).any() else 2024
tmp = tmp[tmp["ANIO"]==pref_year].copy()
if not tmp.empty:
tmp["FECHA"] = pd.Timestamp("2025-01-01")
df_ipc25 = tmp[["PAIS","FECHA","IPC_YOY_PCT"]].copy()
else:
# Caso ancho: columnas de años
if "PAIS" in cols_up:
c_pais = cols_up["PAIS"]
year_cols = []
for c in anual.columns:
s = str(c).strip()
if s.isdigit() and len(s)==4:
try:
y = int(s)
if 2000 <= y <= 2100:
year_cols.append(c)
except:
pass
if year_cols:
pick = "2025" if "2025" in [str(c) for c in year_cols] else ("2024" if "2024" in [str(c) for c in year_cols] else None)
if pick is not None and pick in anual.columns:
tmp = anual[[c_pais, pick]].copy()
tmp.columns = ["PAIS","IPC_YOY_PCT"]
tmp["FECHA"] = pd.Timestamp("2025-01-01")
df_ipc25 = tmp[["PAIS","FECHA","IPC_YOY_PCT"]].copy()
if df_ipc25.empty:
log13("AVISO: no fabricamos ipc_mensual_2025.csv (no pudimos inferir del diccionario anual).")
# Informe de esquemas
_schema_md_report(
{
"BASE_2024":P_BASE, "P10_2024":P_P10, "P11_2024":P_P11, "P12_STD_2024":P_P12S, "P12_ACTIVE_2024":P_P12A,
"COMPARATIVA_BOTTOM":P_COMP, "REAL_2024":P_REAL, "TRAIN_2021_2023":P_TRN,
"DIM_EDIFICIOS": (P_DIM_ES if os.path.exists(P_DIM_ES) else P_DIM),
"PASO8_METRICAS":P_P8, "IPC_MENSUAL": (P_IPC_ES if os.path.exists(P_IPC_ES) else P_IPC),
"IPC_MENSUAL_2025":(P_IPC25 if os.path.exists(P_IPC25) else P_IPC_ANO),
"BASE_2025":P_BASE_2025, "P11_2025":P_P11_2025, "P12_2025":P_P12_2025
},
{
"BASE_2024":df_base, "P10_2024":df_p10, "P11_2024":df_p11, "P12_STD_2024":df_p12s, "P12_ACTIVE_2024":df_p12a,
"COMPARATIVA_BOTTOM":df_comp, "REAL_2024":df_real, "TRAIN_2021_2023":df_train,
"DIM_EDIFICIOS":df_dim, "PASO8_METRICAS":_read_csv_soft(P_P8),
"IPC_MENSUAL":df_ipc, "IPC_MENSUAL_2025":df_ipc25 if not df_ipc25.empty else _read_csv_soft(P_IPC_ANO),
"BASE_2025":_read_csv_soft(P_BASE_2025, DATE_CANDS), "P11_2025":_read_csv_soft(P_P11_2025, DATE_CANDS),
"P12_2025":_read_csv_soft(P_P12_2025, DATE_CANDS)
},
os.path.join(CARP_INF, "esquema_entradas.md")
)
# Normalizamos esquemas + extraemos columnas de predicción (para 2024)
df_base, base_pred_col = _norm_cols(df_base, None)
df_p10, p10_pred_col = _norm_cols(df_p10, "p10")
df_p11, p11_pred_col = _norm_cols(df_p11, "p11")
df_p12s, p12s_pred_col = _norm_cols(df_p12s, "p12std")
df_p12a, p12a_pred_col = _norm_cols(df_p12a, "p12act")
# Ajustamos contrato al año 2024 por variante
base_2024 = _ensure_contract(df_base, base_pred_col, 2024)
p10_2024 = _ensure_contract(df_p10, p10_pred_col or "yhat_reconc", 2024)
p11_2024 = _ensure_contract(df_p11, p11_pred_col or "yhat_reconc", 2024)
p12s_2024 = _ensure_contract(df_p12s, p12s_pred_col or "yhat_reconc", 2024)
p12a_2024 = _ensure_contract(df_p12a, p12a_pred_col or "yhat_reconc", 2024)
# Construimos escala MASE si tenemos TRAIN
mase_scale = pd.DataFrame()
if not df_train.empty and "cost_float_mod" in df_train.columns:
_ensure_ms_inplace(df_train, ["FECHA"])
mase_scale = _build_mase_scale(df_train)
# ===============================
# 6) MÉTRICAS CLAVE (si hay real)
# ===============================
# Real 2024
real_2024 = pd.DataFrame()
if not df_real.empty and "cost_float_mod" in df_real.columns:
real_2024 = df_real.copy()
_ensure_ms_inplace(real_2024, ["FECHA"])
real_2024 = real_2024.rename(columns={"cost_float_mod":"y_real"})
# Merge helper
def _merge_real(pred_df):
if pred_df.empty or real_2024.empty:
return pd.DataFrame()
cols = ["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]
m = pred_df[cols].merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA","y_real"]],
on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
return m
# Elegimos variante final
chosen_var, availability = _select_variant_availability(base_2024, p10_2024, p11_2024, p12s_2024)
# Construimos tabla de métricas globales por variante disponible
metric_rows = []
var_map = {
"BASE": base_2024, "P10": p10_2024, "P11": p11_2024, "P12_STD": p12s_2024, "P12_ACTIVE": p12a_2024
}
for vname, vdf in var_map.items():
if vdf.empty:
continue
m = _merge_real(vdf) if not real_2024.empty else pd.DataFrame()
if not m.empty:
met = _metrics_block(m, "yhat", "y_real", scale_mase=mase_scale)
else:
met = {"MAE":np.nan,"RMSE":np.nan,"MAPE%":np.nan,"sMAPE%":np.nan,"WAPE%":np.nan,"Bias":np.nan,"MASE":np.nan,"WASE":np.nan}
# Cobertura
if not vdf.empty and not real_2024.empty:
merged = vdf.merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA"]], on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="left")
cobertura = 100.0 * merged["FECHA_y"].notna().mean() if "FECHA_y" in merged.columns else np.nan
else:
cobertura = np.nan
row = {"Variante": vname, **met, "Cobertura%": cobertura}
metric_rows.append(row)
df_metrics_global = pd.DataFrame(metric_rows)
_save_csv(df_metrics_global, os.path.join(CARP_MET, "metricas_globales_2024.csv"))
# Métricas por FM_COST_TYPE × PAIS (si tenemos real y dim)
df_metrics_seg = pd.DataFrame()
if not real_2024.empty and not df_dim.empty:
dim = df_dim.copy()
# Normalizamos PAIS/REGION/ID_BUILDING
colmap_dim = {}
for c in dim.columns:
cu = str(c).upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap_dim[c] = "ID_BUILDING"
elif cu in ("PAIS","COUNTRY","COUNTRY_DEF"):
colmap_dim[c] = "PAIS"
elif cu in ("REGION","REGIÓN"):
colmap_dim[c] = "REGION"
elif cu in ("STATUS","ESTADO"):
colmap_dim[c] = "STATUS"
if colmap_dim:
dim = dim.rename(columns=colmap_dim)
for vname, vdf in var_map.items():
if vdf.empty:
continue
merged = vdf.merge(dim[["ID_BUILDING","PAIS"]] if "PAIS" in dim.columns else vdf[["ID_BUILDING"]].assign(PAIS="SIN_PAIS"),
on="ID_BUILDING", how="left")
merged = merged.merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA","y_real"]],
on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
if merged.empty:
continue
rows = []
for (fmc, pais), g in merged.groupby(["FM_COST_TYPE","PAIS"] if "PAIS" in merged.columns else ["FM_COST_TYPE"]):
met = _metrics_block(g, "yhat","y_real", scale_mase=mase_scale)
base = {"Variante": vname, "FM_COST_TYPE":fmc}
if "PAIS" in merged.columns:
base["PAIS"]=pais
rows.append({**base, **met})
if rows:
segi = pd.DataFrame(rows)
df_metrics_seg = pd.concat([df_metrics_seg, segi], ignore_index=True)
else:
log13("AVISO: sin reales 2024 no generamos métricas segmentadas.")
_save_csv(df_metrics_seg, os.path.join(CARP_MET, "metricas_por_fmcost_pais_2024.csv"))
# Subconjunto estable 2024
df_subset = pd.DataFrame()
if chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
merged = _merge_real(chosen_df)
if not merged.empty:
rows = []
ms = mase_scale if not mase_scale.empty else None
for (bid, fmc), g in merged.groupby(["ID_BUILDING","FM_COST_TYPE"]):
met = _metrics_block(g, "yhat","y_real", scale_mase=ms)
rows.append({"ID_BUILDING":bid,"FM_COST_TYPE":fmc, **met})
subm = pd.DataFrame(rows)
mask = (subm["MASE"]<=UMBRAL_MASE12) & (subm["sMAPE%"]<=UMBRAL_SMAPE)
df_subset = subm.loc[mask].copy()
else:
log13("AVISO: no tenemos variante elegida y real suficiente para subset estable.")
_save_csv(df_subset, os.path.join(CARP_MET, "subconjunto_estable_2024.csv"))
# Top-N (si tenemos métricas por pareja)
if not df_subset.empty or (chosen_var is not None and not real_2024.empty):
if df_subset.empty and chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
merged = _merge_real(chosen_df)
rows = []
for (bid, fmc), g in merged.groupby(["ID_BUILDING","FM_COST_TYPE"]):
met = _metrics_block(g, "yhat","y_real", scale_mase=mase_scale if not mase_scale.empty else None)
rows.append({"ID_BUILDING":bid,"FM_COST_TYPE":fmc, **met})
df_pairs = pd.DataFrame(rows)
else:
df_pairs = df_subset.copy()
if "WAPE%" in df_pairs.columns:
top_mej = df_pairs.sort_values("WAPE%", ascending=True).head(N_TOP)
top_peor= df_pairs.sort_values("WAPE%", ascending=False).head(N_TOP)
_save_csv(top_mej, os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(top_peor, os.path.join(CARP_MET, "top_peores_wape.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_peores_wape.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_peores_wape.csv"))
# Top outliers por delta P12 (si tenemos comparativa bottom)
top_out = pd.DataFrame()
if not df_comp.empty:
d = df_comp.copy()
for cand in ["delta_p12std","delta_p12","delta_p11","delta_p10"]:
if cand in d.columns:
d["abs_delta"] = pd.to_numeric(d[cand], errors="coerce").abs()
top_out = d.sort_values("abs_delta", ascending=False).head(N_TOP)
break
_save_csv(top_out, os.path.join(CARP_MET, "top_outliers_delta_p12.csv"))
# =================
# 7) FIGURAS
# =================
# 7.1 WAPE portfolio mensual (variante elegida) si tenemos real
if chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
m = _merge_real(chosen_df)
if not m.empty:
by_m = (m.groupby("FECHA", as_index=False)
.apply(lambda g: pd.Series({"WAPE%": (100.0 * (g["yhat"]-g["y_real"]).abs().sum() /
g["y_real"].abs().sum()) if g["y_real"].abs().sum()!=0 else np.nan})))
plt.figure(figsize=(10,5))
plt.plot(by_m["FECHA"], by_m["WAPE%"])
plt.title(f"WAPE mensual portfolio — {chosen_var} (2024)")
plt.xlabel("Mes"); plt.ylabel("WAPE %")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "wape_portfolio_mensual.png"), dpi=150)
plt.close()
else:
log13("AVISO: sin merge real no generamos figura wape_portfolio_mensual.")
else:
log13("AVISO: sin reales 2024 no generamos figura wape_portfolio_mensual.")
# 7.2 Barras WAPE por FM_COST_TYPE (si tenemos real y dim)
if chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
merged = chosen_df.merge(real_2024, on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
if not merged.empty:
w_rows = []
for fmc, g in merged.groupby("FM_COST_TYPE"):
den = g["y_real"].abs().sum()
wape = 100.0 * (g["yhat"]-g["y_real"]).abs().sum() / den if den!=0 else np.nan
w_rows.append({"FM_COST_TYPE":fmc,"WAPE%":wape})
wdf = pd.DataFrame(w_rows).sort_values("WAPE%", ascending=False)
plt.figure(figsize=(10,5))
plt.bar(wdf["FM_COST_TYPE"], wdf["WAPE%"])
plt.xticks(rotation=45, ha="right")
plt.title(f"WAPE por FM_COST_TYPE — {chosen_var} (2024)")
plt.ylabel("WAPE %")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "wape_por_fmcost.png"), dpi=150)
plt.close()
else:
log13("AVISO: sin merge real no generamos figura wape_por_fmcost.")
else:
log13("AVISO: sin reales 2024 no generamos figura wape_por_fmcost.")
# 7.3 Boxplot de deltas de reconciliación (si hay comparativa)
if not df_comp.empty:
d = df_comp.copy()
delta_cols = [c for c in ["delta_p10","delta_p11","delta_p12","delta_p12std","delta_p12act"] if c in d.columns]
if delta_cols:
plt.figure(figsize=(9,6))
plt.boxplot([pd.to_numeric(d[c], errors="coerce").dropna().values for c in delta_cols], labels=delta_cols, showfliers=False)
plt.axhline(0, color="grey", linestyle=":")
plt.title("Distribución de deltas bottom (reconc - base)")
plt.ylabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "boxplot_deltas_reconciliacion.png"), dpi=150)
plt.close()
else:
log13("AVISO: no fue posible generar boxplot de deltas (faltan columnas delta_*).")
else:
log13("AVISO: comparativa bottom ausente; no generamos boxplot de deltas.")
# =================
# 8) INFORMES
# =================
# 8.1 Resumen ejecutivo (1 página)
resumen_lines = [
"# Resumen ejecutivo — Paso 13",
"",
"Este documento recoge las cifras clave de desempeño 2024, el subconjunto estable, oportunidades rápidas (quick-wins) y las referencias a los principales artefactos del entregable.",
"",
"## 1) Cinco cifras clave (2024)",
]
if not df_metrics_global.empty:
row = None
if chosen_var is not None:
rows = df_metrics_global[df_metrics_global["Variante"].eq(chosen_var)]
if not rows.empty: row = rows.iloc[0]
if row is None:
row = df_metrics_global.iloc[0]
resumen_lines += [
f"- **Variante**: {row['Variante']}",
f"- **WAPE**: {row['WAPE%']:.2f}%",
f"- **sMAPE**: {row['sMAPE%']:.2f}%",
f"- **MASE**: {row['MASE']:.4f}",
f"- **Cobertura**: {row['Cobertura%']:.2f}%"
]
else:
resumen_lines += ["- No disponemos de métricas pobladas (faltan reales o variantes)."]
resumen_lines += [
"",
"## 2) Subconjunto estable",
f"- Fichero: `METRICAS/subconjunto_estable_2024.csv` (filas: {len(df_subset)})",
"",
"## 3) Quick-wins propuestos",
"- Intermitentes/TSB: priorizar pares con alta esporadicidad donde el TSB mejora el error.",
"- Combos/Ensemble: consolidar rutas con mejor MASE/WAPE por segmento.",
"- Outliers (mod_3): revisar top outliers en `METRICAS/top_outliers_delta_p12.csv` y considerar winsorización.",
"",
"## 4) Enlaces a outputs del entregable",
"- `METRICAS/metricas_globales_2024.csv`",
"- `METRICAS/metricas_por_fmcost_pais_2024.csv`",
"- `FIGURAS/wape_portfolio_mensual.png`",
"- `FIGURAS/wape_por_fmcost.png`",
"- `FIGURAS/boxplot_deltas_reconciliacion.png`",
"",
"## 5) Checklist de verificación",
"- Predicciones disponibles para todas las series o fallback documentado.",
"- Ficheros METRICAS completos generados.",
"- Nombres y rutas coherentes con el estándar del proyecto.",
]
with open(os.path.join(CARP_INF, "resumen_ejecutivo.md"), "w", encoding="utf-8") as f:
f.write("\n".join(resumen_lines))
# 8.2 Checklist (casillas de verificación)
check_lines = [
"# Checklist de verificación — Paso 13",
"",
"- [ ] Predicciones 2024 presentes (BASE/P10/P11/P12_STD) o placeholders creados",
"- [ ] Predicciones 2025 presentes (BASE/P11/P12_STD) o placeholders creados",
"- [ ] Métricas globales 2024 generadas",
"- [ ] Métricas por FM_COST_TYPE × PAIS generadas",
"- [ ] Subconjunto estable generado",
"- [ ] Top-N (mejores/peores WAPE) generados",
"- [ ] Outliers delta P12 disponibles",
"- [ ] Figuras generadas (si hay datos)",
"- [ ] Bundle de inferencia listo (manifest.json, predict.py, matrices si aplica)",
]
with open(os.path.join(CARP_INF, "checklist_verificacion.md"), "w", encoding="utf-8") as f:
f.write("\n".join(check_lines))
# =========================================
# 9) EXPORT DE INFERENCIA PARA LA WEB
# (añadimos 2024 y 2025; creamos placeholders si faltan)
# =========================================
# 9.1 Construimos resultados 2024 en ENTREGABLE/RESULTADOS
if base_2024.empty:
base_2024_export = pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
else:
base_2024_export = base_2024.copy()
_save_csv(base_2024_export, os.path.join(CARP_RES, "preds_base_2024.csv"))
_save_csv(p11_2024 if not p11_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2024_P11.csv"))
_save_csv(p12s_2024 if not p12s_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2024_P12STD.csv"))
# 9.2 Resultados 2025 (intentamos leer de entrada; si no, placeholders)
base_2025_raw = _read_csv_soft(P_BASE_2025, DATE_CANDS); base_2025_df, base25_pred = _norm_unpack(base_2025_raw, None)
base_2025 = _ensure_contract(base_2025_df, base25_pred, 2025)
p11_2025_raw = _read_csv_soft(P_P11_2025, DATE_CANDS); p11_2025_df, p11_25_pred = _norm_unpack(p11_2025_raw, "p11")
p11_2025 = _ensure_contract(p11_2025_df, p11_25_pred or "yhat_reconc", 2025)
p12_2025_raw = _read_csv_soft(P_P12_2025, DATE_CANDS); p12_2025_df, p12_25_pred = _norm_unpack(p12_2025_raw, "p12std")
p12_2025 = _ensure_contract(p12_2025_df, p12_25_pred or "yhat_reconc", 2025)
_save_csv(base_2025 if not base_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_base_2025.csv"))
_save_csv(p11_2025 if not p11_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2025_P11.csv"))
_save_csv(p12_2025 if not p12_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2025_P12STD.csv"))
# 9.3 Ruteo por par (modelo final, ensemble, fallback, métricas básicas si disponemos)
def _availability_table(df: pd.DataFrame, year: int, variant: str):
if df.empty:
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","year","variant","available"])
out = (df.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False).size()
.rename(columns={"size":"n"}))
out["year"] = year
out["variant"] = variant
out["available"] = 1
return out[["ID_BUILDING","FM_COST_TYPE","year","variant","available"]]
ruteo_parts = []
ruteo_parts.append(_availability_table(base_2024_export, 2024, "BASE"))
ruteo_parts.append(_availability_table(p11_2024, 2024, "P11"))
ruteo_parts.append(_availability_table(p12s_2024, 2024, "P12_STD"))
ruteo_parts.append(_availability_table(base_2025, 2025, "BASE"))
ruteo_parts.append(_availability_table(p11_2025, 2025, "P11"))
ruteo_parts.append(_availability_table(p12_2025, 2025, "P12_STD"))
ruteo = pd.concat([p for p in ruteo_parts if p is not None and not p.empty], ignore_index=True) if any([not p.empty for p in ruteo_parts if p is not None]) else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","year","variant","available"])
_save_csv(ruteo, os.path.join(CARP_MET, "ruteo_por_par.csv"))
# 9.4 Manifest del bundle
manifest = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"base_path": RUTA_ENTREGABLE,
"results": {
"2024": {
"BASE": "RESULTADOS/preds_base_2024.csv",
"P11": "RESULTADOS/preds_reconciliadas_2024_P11.csv",
"P12_STD":"RESULTADOS/preds_reconciliadas_2024_P12STD.csv"
},
"2025": {
"BASE": "RESULTADOS/preds_base_2025.csv",
"P11": "RESULTADOS/preds_reconciliadas_2025_P11.csv",
"P12_STD":"RESULTADOS/preds_reconciliadas_2025_P12STD.csv"
}
},
"metrics": {
"global_2024": "METRICAS/metricas_globales_2024.csv",
"seg_2024": "METRICAS/metricas_por_fmcost_pais_2024.csv",
"subset_2024": "METRICAS/subconjunto_estable_2024.csv",
"ruteo": "METRICAS/ruteo_por_par.csv"
},
"figures": {
"wape_mensual": "FIGURAS/wape_portfolio_mensual.png",
"wape_fmcost": "FIGURAS/wape_por_fmcost.png",
"box_deltas": "FIGURAS/boxplot_deltas_reconciliacion.png"
}
}
with open(os.path.join(CARP_BUNDLE, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
# 9.5 Copias útiles al entregable (dimensiones e IPC) con nombres españolizados
# Normalizamos dim a español (si procede) y guardamos como dim_edificios.csv
if not df_dim.empty:
dim_out = df_dim.copy()
colmap_dim = {}
for c in dim_out.columns:
cu = str(c).upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap_dim[c] = "ID_BUILDING"
elif cu in ("PAIS","COUNTRY","COUNTRY_DEF"):
colmap_dim[c] = "PAIS"
elif cu in ("REGION","REGIÓN"):
colmap_dim[c] = "REGION"
elif cu in ("STATUS","ESTADO"):
colmap_dim[c] = "STATUS"
if colmap_dim:
dim_out = dim_out.rename(columns=colmap_dim)
_save_csv(dim_out, os.path.join(CARP_MET, "dim_edificios.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "dim_edificios.csv"))
# IPC mensual (lookup real) -> ipc_mensual.csv
if not df_ipc.empty:
# Aseguramos nombres básicos
ipcout = df_ipc.copy()
colmap_ipc = {}
up = {c.upper(): c for c in ipcout.columns}
if "PAIS" not in up:
for k in ("COUNTRY","COUNTRY_DEF"):
if k in up: colmap_ipc[up[k]] = "PAIS"; break
if "FECHA" not in up:
for k in ("DATE","DS","PERIOD","MES","MONTH"):
if k in up: colmap_ipc[up[k]] = "FECHA"; break
if "IPC_YOY_PCT" not in up:
for k in ("IPC","IPC_YOY","YOY","VAR_INTERANUAL","IPC_ANUAL_%"):
if k in up: colmap_ipc[up[k]] = "IPC_YOY_PCT"; break
if colmap_ipc:
ipcout = ipcout.rename(columns=colmap_ipc)
if "FECHA" in ipcout.columns:
ipcout["FECHA"] = pd.to_datetime(ipcout["FECHA"], errors="coerce").dt.to_period("M").dt.to_timestamp()
_save_csv(ipcout, os.path.join(CARP_MET, "ipc_mensual.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "ipc_mensual.csv"))
# IPC 2025 fabricado/real -> ipc_mensual_2025.csv si lo logramos
if not df_ipc25.empty:
# Aseguramos FECHA mensual (si viniera como 2025-01-01 la dejamos tal cual)
ip25 = df_ipc25.copy()
if "FECHA" in ip25.columns:
ip25["FECHA"] = pd.to_datetime(ip25["FECHA"], errors="coerce")
_save_csv(ip25, os.path.join(CARP_MET, "ipc_mensual_2025.csv"))
else:
log13("AVISO: no guardamos ipc_mensual_2025.csv porque no lo pudimos construir ni leer.")
# 9.6 predict.py (para servir desde la web)
predict_py = f'''# predict.py — lector de predicciones 2024/2025 para la web
# Buscamos CSVs en el entregable y devolvemos un DataFrame con (ID_BUILDING, FM_COST_TYPE, FECHA, yhat)
import os, pandas as pd
CSV_SEP = "{CSV_SEP}"
def _auto_base():
# Detectamos base del paquete partiendo de este archivo
here = os.path.abspath(os.path.dirname(__file__))
# El manifest.json está en el mismo directorio
return os.path.abspath(os.path.join(here, ".."))
def _read(path):
if not os.path.exists(path):
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
hdr = pd.read_csv(path, sep=CSV_SEP, nrows=0)
parse_dates = ["FECHA"] if "FECHA" in hdr.columns else None
df = pd.read_csv(path, sep=CSV_SEP, parse_dates=parse_dates)
df.columns = [str(c).strip() for c in df.columns]
# Aseguramos contrato mínimo
need = ["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]
for n in need:
if n not in df.columns:
df[n] = pd.Series(dtype="float64")
return df[need]
def predict(ids, year=2025, variant="P12_STD", ruta_base="__AUTO__"):
"""
ids: lista de ID_BUILDING
year: 2024 o 2025
variant: 'P12_STD' | 'P11' | 'BASE'
ruta_base: si '__AUTO__', resolvemos base a partir de este paquete
"""
if ruta_base == "__AUTO__":
ruta_base = _auto_base()
# Rutas relativas respecto a la base del entregable
if year == 2024:
files = {{
"BASE": os.path.join(ruta_base, "RESULTADOS", "preds_base_2024.csv"),
"P11": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2024_P11.csv"),
"P12_STD": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2024_P12STD.csv")
}}
else:
files = {{
"BASE": os.path.join(ruta_base, "RESULTADOS", "preds_base_2025.csv"),
"P11": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2025_P11.csv"),
"P12_STD": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2025_P12STD.csv")
}}
path = files.get(variant.upper(), files["BASE"])
df = _read(path)
# Filtramos por los IDs solicitados
if ids is not None and len(ids)>0 and "ID_BUILDING" in df.columns:
sids = pd.Series(ids).astype("Int64") if hasattr(pd.Series(ids), "astype") else pd.Series(ids)
df = df[df["ID_BUILDING"].astype("Int64").isin(sids)]
return df
'''
with open(os.path.join(CARP_BUNDLE, "predict.py"), "w", encoding="utf-8") as f:
f.write(predict_py)
# (Opcional) Matrices A / W si existieran en entrada (las traemos al bundle)
A_CAND = os.path.join(RUTA_ENTRADA, "METRICAS", "A_matriz.npz")
W_CAND = os.path.join(RUTA_ENTRADA, "METRICAS", "W_diag.npy")
if os.path.exists(A_CAND):
import shutil
shutil.copy(A_CAND, os.path.join(CARP_BUNDLE, "A_matriz.npz"))
if os.path.exists(W_CAND):
import shutil
shutil.copy(W_CAND, os.path.join(CARP_BUNDLE, "W_diag.npy"))
log13("Paso 13 finalizado.")
[2025-09-27T17:19:04] Iniciamos Paso 13 — Paquete de entrega + Hoja de ruta. [2025-09-27T17:19:05] FALTA METRICAS/dim_edificios.csv. Seguimos sin romper. [2025-09-27T17:19:05] FALTA METRICAS/ipc_mensual.csv. Seguimos sin romper. [2025-09-27T17:19:05] FALTA METRICAS/ipc_mensual_2025.csv. Seguimos sin romper. [2025-09-27T17:19:05] AVISO: no fabricamos ipc_mensual_2025.csv (no pudimos inferir del diccionario anual). [2025-09-27T17:19:05] Guardado informe de esquema: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/INFORMES/esquema_entradas.md [2025-09-27T17:19:06] Variante final seleccionada: P12_STD [2025-09-27T17:19:06] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metricas_globales_2024.csv (5 filas). [2025-09-27T17:19:09] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metricas_por_fmcost_pais_2024.csv (215 filas). [2025-09-27T17:19:36] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/subconjunto_estable_2024.csv (0 filas). [2025-09-27T17:19:59] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_mejores_wape.csv (50 filas). [2025-09-27T17:19:59] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_peores_wape.csv (50 filas). [2025-09-27T17:19:59] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_outliers_delta_p12.csv (50 filas). [2025-09-27T17:20:01] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_base_2024.csv (29148 filas). [2025-09-27T17:20:01] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2024_P11.csv (29148 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2024_P12STD.csv (29148 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_base_2025.csv (0 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2025_P11.csv (0 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2025_P12STD.csv (0 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/ruteo_por_par.csv (7287 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/dim_edificios.csv (1650 filas). [2025-09-27T17:20:02] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/ipc_mensual.csv (12 filas). [2025-09-27T17:20:02] AVISO: no guardamos ipc_mensual_2025.csv porque no lo pudimos construir ni leer. [2025-09-27T17:20:02] Paso 13 finalizado.
No ejecutar - Bloque preliminar Paso 13 -¶
Bloque preliminar Paso 13 en el que hemos resuelto una incidencias con la estructura del metrics_subset_estable
.csv (obtenido en el paso 9, bloque 9). Es necesario que lo partamos en dos bloques, uno para portfolio (global) y otro para los segmentos, porque es necesario disponer de ellos en el paso 13 puesto son parte de la explicación de la estabilidad del dataset a predecir.
# ---------- RUTAS ----------
ruta_origen = Path("/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable.csv")
ruta_portfolio = ruta_origen.with_name("metrics_subset_estable_portfolio.csv")
ruta_segmento = ruta_origen.with_name("metrics_subset_estable_segmento.csv")
# ---------- PARSER ROBUSTO ----------
def _smart_split(line: str):
"""Divide una línea usando ';' o '\t' (elige el que más columnas dé)."""
line = line.rstrip("\n").rstrip("\r")
if not line:
return []
parts_sc = line.split(";")
parts_tb = line.split("\t")
# elegimos el separador que produzca MÁS columnas
return parts_tb if len(parts_tb) > len(parts_sc) else parts_sc
def cargar_metrics_subset_estable(ruta: Path):
"""
Lee el CSV mixto (portfolio + segmento), aunque haya separadores distintos
y columnas extra en SEGMENTO.
Devuelve dos DataFrames: df_portfolio, df_segmento
"""
# Esquema canonizado
cols_port = ["FECHA","MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","scope"]
cols_segm = ["DIM_NAME","DIM_VALUE","FECHA","MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","flag","scope"]
rows_port, rows_seg = [], []
with ruta.open("r", encoding="utf-8") as f:
# Intentamos detectar si hay cabecera en la primera línea
first = f.readline()
parts = _smart_split(first)
has_header = any(str(p).strip().upper() in {"FECHA","MAE","SMAPE","WAPE"} for p in parts)
if not has_header and parts:
# Procesamos esa primera línea como datos
if len(parts) >= 12: # SEGMENTO
rows_seg.append(parts[:11] + [";".join(parts[11:]) if len(parts) > 12 else parts[11]])
elif len(parts) >= 9: # PORTFOLIO
rows_port.append(parts[:8] + [";".join(parts[8:]) if len(parts) > 9 else parts[8]])
# Resto de líneas
for line in f:
parts = _smart_split(line)
if not parts:
continue
if len(parts) >= 12:
# SEGMENTO: tomamos 12 campos (si hay más, lo que sobra lo unimos dentro de 'scope')
seg = parts[:11]
scope = ";".join(parts[11:]) # por si el scope tuviera ';' o '\t' embebidos
rows_seg.append(seg + [scope])
elif len(parts) >= 9:
# PORTFOLIO
port = parts[:8]
scope = ";".join(parts[8:])
rows_port.append(port + [scope])
else:
# línea demasiado corta -> la ignoramos
continue
# Construcción de DataFrames
df_port = pd.DataFrame(rows_port, columns=cols_port) if rows_port else pd.DataFrame(columns=cols_port)
df_seg = pd.DataFrame(rows_seg, columns=cols_segm) if rows_seg else pd.DataFrame(columns=cols_segm)
# Normalización de tipos
def _to_num(df, cols):
for c in cols:
if c in df.columns:
df[c] = pd.to_numeric(df[c], errors="coerce")
# PORTFOLIO
if not df_port.empty:
df_port["FECHA"] = pd.to_datetime(df_port["FECHA"], errors="coerce")
_to_num(df_port, ["MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes"])
df_port["scope"] = df_port["scope"].astype(str).str.strip().str.upper()
# SEGMENTO
if not df_seg.empty:
df_seg["FECHA"] = pd.to_datetime(df_seg["FECHA"], errors="coerce")
_to_num(df_seg, ["MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","flag"])
df_seg["DIM_NAME"] = df_seg["DIM_NAME"].astype(str).str.strip()
df_seg["DIM_VALUE"] = df_seg["DIM_VALUE"].astype(str).str.strip()
df_seg["scope"] = df_seg["scope"].astype(str).str.strip().str.upper()
# Filtrado final por scope (por si viniera mezclado)
if "scope" in df_port.columns:
df_port = df_port[df_port["scope"].eq("PORTFOLIO")].copy()
if "scope" in df_seg.columns:
df_seg = df_seg[df_seg["scope"].eq("SEGMENTO")].copy()
return df_port, df_seg
# ---------- EJECUCIÓN ----------
df_portfolio, df_segmento = cargar_metrics_subset_estable(ruta_origen)
# Guardamos
df_portfolio.to_csv(ruta_portfolio, sep=";", index=False)
df_segmento.to_csv(ruta_segmento, sep=";", index=False)
print("PORTFOLIO -> filas:", df_portfolio.shape[0], "columnas:", df_portfolio.shape[1])
print("SEGMENTO -> filas:", df_segmento.shape[0], "columnas:", df_segmento.shape[1])
print("Guardados en:\n -", ruta_portfolio, "\n -", ruta_segmento)
# Vistazo rápido de los segmentos detectados
if not df_segmento.empty:
print("\nDimensiones de segmento encontradas:")
print(df_segmento["DIM_NAME"].value_counts())
print("\nEjemplos:")
print(df_segmento.head(5))
PORTFOLIO → filas: 12 columnas: 9 SEGMENTO → filas: 1560 columnas: 12 Guardados en: - /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable_portfolio.csv - /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable_segmento.csv Dimensiones de segmento encontradas: DIM_NAME REGION 1212 TIPO_USO 168 FM_COST_TYPE 96 PAIS 84 Name: count, dtype: int64 Ejemplos: DIM_NAME DIM_VALUE FECHA MAE SMAPE \ 0 FM_COST_TYPE Eficiencia Energética 2024-01-01 113.729380 29.980846 1 FM_COST_TYPE Eficiencia Energética 2024-02-01 279.804372 24.098283 2 FM_COST_TYPE Eficiencia Energética 2024-03-01 73.678876 11.336812 3 FM_COST_TYPE Eficiencia Energética 2024-04-01 97.060095 26.780269 4 FM_COST_TYPE Eficiencia Energética 2024-05-01 412.795984 22.038239 WAPE MASE1 MASE12 n_series_mes peso_gasto_mes flag scope 0 0.317148 2.552630 0.422272 53.0 19005.824455 1.0 SEGMENTO 1 0.394905 2.682955 0.571354 53.0 37552.424455 1.0 SEGMENTO 2 0.205462 0.059328 0.065404 53.0 19005.824455 1.0 SEGMENTO 3 0.282817 2.617944 0.375707 53.0 18189.088091 1.0 SEGMENTO 4 0.595557 3.062982 0.573264 53.0 36735.688091 1.0 SEGMENTO
Cargamos y revisamos
# Rutas de los nuevos ficheros
ruta_portfolio = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable_portfolio.csv"
ruta_segmento = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/METRICAS/metrics_subset_estable_segmento.csv"
# Cargar los dos CSV
df_portfolio = pd.read_csv(ruta_portfolio, sep=";")
df_segmento = pd.read_csv(ruta_segmento, sep=";")
# Información básica
print("PORTFOLIO -> filas:", df_portfolio.shape[0], "columnas:", df_portfolio.shape[1])
print("SEGMENTO --> filas:", df_segmento.shape[0], "columnas:", df_segmento.shape[1])
# Mostrar primeras filas de cada dataframe
print("\nPrimeras filas - PORTFOLIO:")
print(df_portfolio.head())
print("\nPrimeras filas - SEGMENTO:")
print(df_segmento.head())
PORTFOLIO → filas: 12 columnas: 9 SEGMENTO → filas: 1560 columnas: 12 Primeras filas - PORTFOLIO: FECHA MAE SMAPE WAPE MASE1 MASE12 \ 0 2024-01-01 415.751344 76.869628 0.286340 2.501419 4.006826 1 2024-02-01 494.764016 77.727396 0.358687 2.149515 2.814806 2 2024-03-01 386.877205 77.150171 0.283530 2.103683 5.993599 3 2024-04-01 416.530706 77.473258 0.291653 2.455745 2.634847 4 2024-05-01 408.472574 82.163432 0.306586 2.372222 4.252092 n_series_mes peso_gasto_mes scope 0 2231.0 3.239299e+06 PORTFOLIO 1 2231.0 3.077384e+06 PORTFOLIO 2 2231.0 3.044203e+06 PORTFOLIO 3 2231.0 3.186256e+06 PORTFOLIO 4 2231.0 2.972418e+06 PORTFOLIO Primeras filas - SEGMENTO: DIM_NAME DIM_VALUE FECHA MAE SMAPE \ 0 FM_COST_TYPE Eficiencia Energética 2024-01-01 113.729380 29.980846 1 FM_COST_TYPE Eficiencia Energética 2024-02-01 279.804372 24.098283 2 FM_COST_TYPE Eficiencia Energética 2024-03-01 73.678876 11.336812 3 FM_COST_TYPE Eficiencia Energética 2024-04-01 97.060095 26.780269 4 FM_COST_TYPE Eficiencia Energética 2024-05-01 412.795984 22.038239 WAPE MASE1 MASE12 n_series_mes peso_gasto_mes flag scope 0 0.317148 2.552630 0.422272 53.0 19005.824455 1.0 SEGMENTO 1 0.394905 2.682955 0.571354 53.0 37552.424455 1.0 SEGMENTO 2 0.205462 0.059328 0.065404 53.0 19005.824455 1.0 SEGMENTO 3 0.282817 2.617944 0.375707 53.0 18189.088091 1.0 SEGMENTO 4 0.595557 3.062982 0.573264 53.0 36735.688091 1.0 SEGMENTO
Ahora vamos a integrar esta partición al paso 13 para proceder con la recopilación de ficheros procesados y obtenidos en la estrategia 3 para guardarlo en un entregable que nos permita visualizarlo en la web.
# ============================================
# PASO 13 — Paquete de entrega + Hoja de ruta
# ============================================
# En este script:
# - Preparamos el entregable "PRIMER_ENTREGABLE"
# - Leemos artefactos de 2024/2025 (sin romper si faltan)
# - Calculamos métricas/figuras/informes si hay datos
# - Generamos paquete para las inferencia en la web (2024 y 2025)
# - Creamos placeholders para 2025 y otros cuando algo falte para mantener el flujo estable
# - Integramos fuentes reales: dim_buildings.csv, ipc_monthly_lookup.csv e ipc_anual_diccionario.csv
# - Alineamos a la política de reconciliación del modelo con el diagnóstico: P10 global por defecto; reglas por país/tipo
# - Realizamos la partición y normalizamos metrics_subset_estable.csv -> portfolio / segmento
# ==================
# SEMILLA
# ==================
np.random.seed(7)
# ==================
# 0) PARÁMETROS
# ==================
# Ruta de ENTRADA (artefactos generados por pasos previos)
RUTA_ENTRADA = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3"
# Ruta del ENTREGABLE (salida de este paso 13)
ruta_base_3 = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE"
# CSV y umbrales
CSV_SEP = ";"
ZERO_THR = 1e-9
N_TOP = 50
UMBRAL_MASE12 = 1.0
UMBRAL_SMAPE = 30.0
# Variante global por defecto según diagnóstico (no según disponibilidad)
VAR_FINAL_DEF = "P10"
# Candidatas de fecha
DATE_CANDS = ["FECHA","Date","DS","PERIOD","MONTH","Mes","MES"]
# ==================
# 1) CARPETAS SALIDA
# ==================
RUTA_ENTREGABLE = ruta_base_3
CARP_RES = os.path.join(RUTA_ENTREGABLE, "RESULTADOS")
CARP_MET = os.path.join(RUTA_ENTREGABLE, "METRICAS")
CARP_FIG = os.path.join(RUTA_ENTREGABLE, "FIGURAS")
CARP_INF = os.path.join(RUTA_ENTREGABLE, "INFORMES")
CARP_LOG = os.path.join(RUTA_ENTREGABLE, "REGISTROS")
CARP_BUNDLE = os.path.join(RUTA_ENTREGABLE, "PAQUETE_INFERENCIA")
for d in [RUTA_ENTREGABLE, CARP_RES, CARP_MET, CARP_FIG, CARP_INF, CARP_LOG, CARP_BUNDLE]:
os.makedirs(d, exist_ok=True)
# ============
# 2) LOGGING
# ============
LOG_PATH = os.path.join(CARP_LOG, "paso13.log")
def log13(msg: str):
ts = datetime.now().isoformat(timespec="seconds")
line = f"[{ts}] {msg}"
print(line)
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
log13("Iniciamos Paso 13 — Paquete de entrega + Hoja de ruta (alineado con diagnóstico).")
# =====================
# 3) HELPERS GENERALES
# =====================
def _ensure_ms_inplace(df: pd.DataFrame, candidate_cols=DATE_CANDS):
if df is None or df.empty: return
for c in candidate_cols:
if c in df.columns:
try:
df[c] = pd.to_datetime(df[c], errors="coerce").dt.to_period("M").dt.to_timestamp()
except Exception:
pass
def _read_csv_soft(path: str, candidate_date_cols=None, sep=CSV_SEP) -> pd.DataFrame:
if not os.path.exists(path):
rel = path.replace(RUTA_ENTRADA, "").lstrip("/").lstrip("\\")
log13(f"FALTA {rel}. Seguimos sin romper.")
return pd.DataFrame()
try:
header = pd.read_csv(path, sep=sep, nrows=0)
header_cols = [str(c).strip().lstrip("\ufeff") for c in header.columns]
parse_dates = None
if candidate_date_cols:
parse_dates = [c for c in candidate_date_cols if c in header_cols] or None
df = pd.read_csv(path, sep=sep, parse_dates=parse_dates)
df.columns = [str(c).strip().lstrip("\ufeff") for c in df.columns]
return df
except Exception as e:
log13(f"ERROR leyendo {path}: {e}. Devolvemos DF vacío.")
return pd.DataFrame()
def _norm_cols(df: pd.DataFrame, variant_tag: str=None):
if df is None or df.empty:
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]), None
out = df.copy()
colmap = {}
for c in out.columns:
cu = str(c).strip().upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap[c] = "ID_BUILDING"
elif cu in ("FM_COST_TYPE","FM_COST","FMCOST","COST_TYPE"):
colmap[c] = "FM_COST_TYPE"
elif cu in ("FECHA","DATE","DS","PERIOD","MONTH","MES"):
colmap[c] = "FECHA"
if colmap:
out = out.rename(columns=colmap)
pred_col = None
for candidate in ["yhat_reconc","yhat_combo","yhat","YHAT","pred","PRED","Y","valor","VALOR","forecast"]:
if candidate in out.columns:
pred_col = candidate
break
if variant_tag and not pred_col:
tagged = f"yhat_reconc_{variant_tag}"
if tagged in out.columns:
pred_col = tagged
return out, pred_col
def _norm_unpack(df, variant_tag=None):
ret = _norm_cols(df, variant_tag)
if isinstance(ret, tuple) and len(ret) == 2:
return ret[0], ret[1]
return ret, None
def _ensure_contract(df: pd.DataFrame, pred_col: str, year: int) -> pd.DataFrame:
cols = ["ID_BUILDING","FM_COST_TYPE","FECHA", pred_col] if pred_col else ["ID_BUILDING","FM_COST_TYPE","FECHA"]
df2 = df.copy()[[c for c in cols if c in df.columns]] if not df.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
if not df2.empty and pred_col and pred_col != "yhat":
df2 = df2.rename(columns={pred_col: "yhat"})
if "FECHA" in df2.columns:
_ensure_ms_inplace(df2, ["FECHA"])
try:
df2 = df2[df2["FECHA"].dt.year.eq(year)]
except Exception:
pass
if "ID_BUILDING" in df2.columns:
df2["ID_BUILDING"] = pd.to_numeric(df2["ID_BUILDING"], errors="coerce").astype("Int64")
if "FM_COST_TYPE" in df2.columns:
df2["FM_COST_TYPE"] = df2["FM_COST_TYPE"].astype(str).str.strip()
return df2
def _save_csv(df: pd.DataFrame, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
df.to_csv(path, sep=CSV_SEP, index=False)
log13(f"Guardado CSV: {path} ({len(df)} filas).")
def _safe_div(num, den):
return num/den if (den is not None and den != 0) else np.nan
def _metrics_block(df: pd.DataFrame, col_pred: str, col_real: str, scale_mase=None):
if df.empty or col_pred not in df.columns or col_real not in df.columns:
return {"MAE":np.nan,"RMSE":np.nan,"MAPE%":np.nan,"sMAPE%":np.nan,"WAPE%":np.nan,"Bias":np.nan,"MASE":np.nan,"WASE":np.nan}
y = pd.to_numeric(df[col_real], errors="coerce")
yhat = pd.to_numeric(df[col_pred], errors="coerce")
err = yhat - y
abs_e = err.abs()
mae = abs_e.mean()
rmse = float(np.sqrt((err**2).mean())) if len(err)>0 else np.nan
mape = float((100.0 * (abs_e / y.replace(0,np.nan).abs())).mean())
smape= float((100.0 * (abs_e / ((y.abs() + yhat.abs())/2.0).replace(0,np.nan))).mean())
wape = float(100.0 * abs_e.sum() / y.abs().sum()) if y.abs().sum()!=0 else np.nan
bias = err.mean()
if scale_mase is not None and "MASE_scale" in scale_mase.columns:
df_sc = df.merge(scale_mase, on=["ID_BUILDING","FM_COST_TYPE"], how="left")
scale = pd.to_numeric(df_sc["MASE_scale"], errors="coerce").replace(0,np.nan)
mase = float((abs_e / scale).mean())
wase = float(abs_e.sum() / scale.sum()) if pd.notna(scale.sum()) and scale.sum()!=0 else np.nan
else:
mase, wase = np.nan, np.nan
return {"MAE":mae,"RMSE":rmse,"MAPE%":mape,"sMAPE%":smape,"WAPE%":wape,"Bias":bias,"MASE":mase,"WASE":wase}
def _build_mase_scale(train_df: pd.DataFrame):
if train_df.empty or "FECHA" not in train_df.columns or "cost_float_mod" not in train_df.columns:
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","MASE_scale"])
t = train_df.copy()
_ensure_ms_inplace(t, ["FECHA"])
t = t.sort_values(["ID_BUILDING","FM_COST_TYPE","FECHA"])
t["y_lag12"] = t.groupby(["ID_BUILDING","FM_COST_TYPE"])["cost_float_mod"].shift(12)
t["abs_diff12"] = (t["cost_float_mod"] - t["y_lag12"]).abs()
sc = (t.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False)["abs_diff12"]
.mean().rename(columns={"abs_diff12":"MASE_scale"}))
sc["MASE_scale"] = pd.to_numeric(sc["MASE_scale"], errors="coerce").fillna(0.0)
sc.loc[sc["MASE_scale"]<=0, "MASE_scale"] = 1e-8
return sc
def _schema_md_report(paths_dict: dict, frames_dict: dict, out_md: str):
lines = ["# Esquema de entradas — Paso 13\n"]
for k, p in paths_dict.items():
lines.append(f"## {k}\n")
lines.append(f"- Ruta: `{p}`")
df = frames_dict.get(k, pd.DataFrame())
if df is None or df.empty:
lines.append("- Estado: **vacío o ausente**\n")
else:
cols = ", ".join(df.columns.tolist())
n = len(df)
lines.append(f"- Filas: {n}")
lines.append(f"- Columnas: {cols}\n")
with open(out_md, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log13(f"Guardado informe de esquema: {out_md}")
# Helper robusto para leer metrics_subset_estable.csv mixto (portfolio/segmento) ---
def _smart_split_line(line: str):
line = line.rstrip("\n").rstrip("\r")
if not line: return []
parts_sc = line.split(";")
parts_tb = line.split("\t")
return parts_tb if len(parts_tb) > len(parts_sc) else parts_sc
def _load_metrics_subset_estable_mixto(path: str):
"""
Lee el CSV del Paso 9 aunque mezcle PORTFOLIO (9 campos) y SEGMENTO (12).
Devuelve dos DataFrames: df_portfolio, df_segmento (ya normalizados).
"""
cols_port = ["FECHA","MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","scope"]
cols_segm = ["DIM_NAME","DIM_VALUE","FECHA","MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","flag","scope"]
rows_port, rows_seg = [], []
if not os.path.exists(path):
return pd.DataFrame(columns=cols_port), pd.DataFrame(columns=cols_segm)
with open(path, "r", encoding="utf-8") as f:
first = f.readline()
parts = _smart_split_line(first)
has_header = any(str(p).strip().upper() in {"FECHA","MAE","SMAPE","WAPE"} for p in parts)
if not has_header and parts:
if len(parts) >= 12:
rows_seg.append(parts[:11] + [";".join(parts[11:])])
elif len(parts) >= 9:
rows_port.append(parts[:8] + [";".join(parts[8:])])
for line in f:
parts = _smart_split_line(line)
if not parts:
continue
if len(parts) >= 12:
seg = parts[:11]; scope = ";".join(parts[11:])
rows_seg.append(seg + [scope])
elif len(parts) >= 9:
port = parts[:8]; scope = ";".join(parts[8:])
rows_port.append(port + [scope])
df_port = pd.DataFrame(rows_port, columns=cols_port) if rows_port else pd.DataFrame(columns=cols_port)
df_seg = pd.DataFrame(rows_seg, columns=cols_segm) if rows_seg else pd.DataFrame(columns=cols_segm)
if not df_port.empty:
df_port["FECHA"] = pd.to_datetime(df_port["FECHA"], errors="coerce")
for c in ["MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes"]:
df_port[c] = pd.to_numeric(df_port[c], errors="coerce")
df_port["scope"] = df_port["scope"].astype(str).str.strip().str.upper()
df_port = df_port[df_port["scope"].eq("PORTFOLIO")].copy()
if not df_seg.empty:
df_seg["FECHA"] = pd.to_datetime(df_seg["FECHA"], errors="coerce")
for c in ["MAE","SMAPE","WAPE","MASE1","MASE12","n_series_mes","peso_gasto_mes","flag"]:
df_seg[c] = pd.to_numeric(df_seg[c], errors="coerce")
df_seg["DIM_NAME"] = df_seg["DIM_NAME"].astype(str).str.strip()
df_seg["DIM_VALUE"] = df_seg["DIM_VALUE"].astype(str).str.strip()
df_seg["scope"] = df_seg["scope"].astype(str).str.strip().str.upper()
df_seg = df_seg[df_seg["scope"].eq("SEGMENTO")].copy()
return df_port, df_seg
# IPC mensual: intentamos el mensual real y, si no hay, derivamos desde anual
def _load_ipc_monthly(P_IPC_MENSUAL_REAL, P_IPC_ANUAL_DICT, P_IPC25, date_cols=DATE_CANDS):
mensual = pd.DataFrame()
if os.path.exists(P_IPC_MENSUAL_REAL):
mensual = _read_csv_soft(P_IPC_MENSUAL_REAL, candidate_date_cols=date_cols)
if not mensual.empty:
if "FECHA" not in mensual.columns:
for c in ["Date","DS","PERIOD","MONTH","Mes","MES"]:
if c in mensual.columns:
mensual = mensual.rename(columns={c:"FECHA"})
break
_ensure_ms_inplace(mensual, ["FECHA"])
if os.path.exists(P_IPC25):
ipc25 = _read_csv_soft(P_IPC25, candidate_date_cols=date_cols)
if not ipc25.empty:
if "FECHA" not in ipc25.columns:
for c in ["Date","DS","PERIOD","MONTH","Mes","MES"]:
if c in ipc25.columns:
ipc25 = ipc25.rename(columns={c:"FECHA"})
break
_ensure_ms_inplace(ipc25, ["FECHA"])
mensual = pd.concat([mensual, ipc25], ignore_index=True) if not mensual.empty else ipc25
if mensual.empty and os.path.exists(P_IPC_ANUAL_DICT):
anual = _read_csv_soft(P_IPC_ANUAL_DICT)
up = {c.upper(): c for c in anual.columns}
pais_col = up.get("PAIS") or up.get("COUNTRY")
year_col = up.get("ANO") or up.get("AÑO") or up.get("YEAR")
val_col = up.get("IPC") or up.get("IPC_YOY_PCT") or up.get("VALOR") or up.get("INDEX")
if pais_col and year_col and val_col:
rows = []
for _, r in anual.iterrows():
pais = r[pais_col]
y = int(r[year_col])
val = float(r[val_col])
for m in range(1,13):
rows.append({"PAIS": pais, "FECHA": pd.Timestamp(y, m, 1), "IPC_YOY_PCT": val})
mensual = pd.DataFrame(rows)
else:
log13("AVISO: ipc_anual_diccionario.csv no tiene columnas reconocibles (PAIS/YEAR/IPC). IPC mensual quedará vacío.")
return mensual
# ==================================================
# 4) RUTAS DE ENTRADA
# ==================================================
# 2024
P_BASE = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_por_serie_2024.csv")
P_P10 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2024.csv")
P_P11 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2024_prod.csv")
P_P12S = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_step12.csv")
P_P12A = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_step12_activeonly.csv")
P_COMP = os.path.join(RUTA_ENTRADA, "REPORTING", "compare_recon_steps_bottom.csv")
P_REAL = os.path.join(RUTA_ENTRADA, "RESULTADOS", "test_full_2024.csv")
P_TRN = os.path.join(RUTA_ENTRADA, "RESULTADOS", "train_full_2021_2023.csv")
# Dimensiones e IPC (fuentes reales)
P_DIM = os.path.join(RUTA_ENTRADA, "METRICAS", "dim_buildings.csv")
P_DIM_ES = os.path.join(RUTA_ENTRADA, "METRICAS", "dim_edificios.csv")
P_P8 = os.path.join(RUTA_ENTRADA, "METRICAS", "paso8_metrics_2024_por_pareja.csv")
P_IPC_REAL = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_monthly_lookup.csv")
P_IPC_ES = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_mensual.csv")
P_IPC25 = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_mensual_2025.csv")
P_IPC_ANO = os.path.join(RUTA_ENTRADA, "METRICAS", "ipc_anual_diccionario.csv")
# Subset estable "oficial" del Paso 9 (si existe)
P_SUBSET_P9 = os.path.join(RUTA_ENTRADA, "METRICAS", "metrics_subset_estable.csv")
P_SUBSET_PORT = os.path.join(RUTA_ENTRADA, "METRICAS", "metrics_subset_estable_portfolio.csv")
P_SUBSET_SEGM = os.path.join(RUTA_ENTRADA, "METRICAS", "metrics_subset_estable_segmento.csv")
# 2025 (si ya existen)
P_BASE_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_base_2025.csv")
P_P11_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2025_P11.csv")
P_P12_2025 = os.path.join(RUTA_ENTRADA, "RESULTADOS", "preds_reconciliadas_2025_P12STD.csv")
# ==========================
# 5) LECTURA SUAVE ENTRADAS
# ==========================
df_base = _read_csv_soft(P_BASE, candidate_date_cols=DATE_CANDS)
df_p10 = _read_csv_soft(P_P10, candidate_date_cols=DATE_CANDS)
df_p11 = _read_csv_soft(P_P11, candidate_date_cols=DATE_CANDS)
df_p12s = _read_csv_soft(P_P12S, candidate_date_cols=DATE_CANDS)
df_p12a = _read_csv_soft(P_P12A, candidate_date_cols=DATE_CANDS)
df_comp = _read_csv_soft(P_COMP, candidate_date_cols=DATE_CANDS)
df_real = _read_csv_soft(P_REAL, candidate_date_cols=DATE_CANDS)
df_train = _read_csv_soft(P_TRN, candidate_date_cols=DATE_CANDS)
# Dimensiones: preferimos españolizado si existiera; si no, usamos el real
df_dim = _read_csv_soft(P_DIM_ES)
if df_dim.empty:
df_dim = _read_csv_soft(P_DIM)
# IPC mensual consolidado
df_ipc = _load_ipc_monthly(P_IPC_REAL, P_IPC_ANO, P_IPC25, DATE_CANDS)
df_ipc25 = df_ipc[df_ipc["FECHA"].dt.year.eq(2025)] if not df_ipc.empty and "FECHA" in df_ipc.columns else pd.DataFrame()
# Informe de esquemas
_schema_md_report(
{
"BASE_2024":P_BASE, "P10_2024":P_P10, "P11_2024":P_P11, "P12_STD_2024":P_P12S, "P12_ACTIVE_2024":P_P12A,
"COMPARATIVA_BOTTOM":P_COMP, "REAL_2024":P_REAL, "TRAIN_2021_2023":P_TRN,
"DIM_EDIFICIOS": (P_DIM_ES if os.path.exists(P_DIM_ES) else P_DIM),
"PASO8_METRICAS":P_P8, "IPC_MENSUAL(merged)": (P_IPC_ES if os.path.exists(P_IPC_ES) else P_IPC_REAL),
"IPC_ANUAL_DICC":P_IPC_ANO, "IPC_MENSUAL_2025":P_IPC25,
"BASE_2025":P_BASE_2025, "P11_2025":P_P11_2025, "P12_2025":P_P12_2025
},
{
"BASE_2024":df_base, "P10_2024":df_p10, "P11_2024":df_p11, "P12_STD_2024":df_p12s, "P12_ACTIVE_2024":df_p12a,
"COMPARATIVA_BOTTOM":df_comp, "REAL_2024":df_real, "TRAIN_2021_2023":df_train,
"DIM_EDIFICIOS":df_dim, "PASO8_METRICAS":_read_csv_soft(P_P8),
"IPC_MENSUAL(merged)":df_ipc, "IPC_ANUAL_DICC":_read_csv_soft(P_IPC_ANO), "IPC_MENSUAL_2025":df_ipc25,
"BASE_2025":_read_csv_soft(P_BASE_2025, DATE_CANDS), "P11_2025":_read_csv_soft(P_P11_2025, DATE_CANDS),
"P12_2025":_read_csv_soft(P_P12_2025, DATE_CANDS)
},
os.path.join(CARP_INF, "esquema_entradas.md")
)
# Normalizamos + extraemos columnas de predicción (2024)
df_base, base_pred_col = _norm_cols(df_base, None)
df_p10, p10_pred_col = _norm_cols(df_p10, "p10")
df_p11, p11_pred_col = _norm_cols(df_p11, "p11")
df_p12s, p12s_pred_col = _norm_cols(df_p12s, "p12std")
df_p12a, p12a_pred_col = _norm_cols(df_p12a, "p12act")
# Contrato 2024 por variante
base_2024 = _ensure_contract(df_base, base_pred_col, 2024)
p10_2024 = _ensure_contract(df_p10, p10_pred_col or "yhat_reconc", 2024)
p11_2024 = _ensure_contract(df_p11, p11_pred_col or "yhat_reconc", 2024)
p12s_2024 = _ensure_contract(df_p12s, p12s_pred_col or "yhat_reconc", 2024)
p12a_2024 = _ensure_contract(df_p12a, p12a_pred_col or "yhat_reconc", 2024)
# Escala MASE si tenemos TRAIN
mase_scale = pd.DataFrame()
if not df_train.empty and "cost_float_mod" in df_train.columns:
_ensure_ms_inplace(df_train, ["FECHA"])
mase_scale = _build_mase_scale(df_train)
# ===============================
# 6) MÉTRICAS CLAVE (si hay real)
# ===============================
real_2024 = pd.DataFrame()
if not df_real.empty and "cost_float_mod" in df_real.columns:
real_2024 = df_real.copy()
_ensure_ms_inplace(real_2024, ["FECHA"])
real_2024 = real_2024.rename(columns={"cost_float_mod":"y_real"})
def _merge_real(pred_df):
if pred_df.empty or real_2024.empty:
return pd.DataFrame()
cols = ["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]
m = pred_df[cols].merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA","y_real"]],
on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
return m
def _select_variant_availability_for_metrics(base_2024, p10_2024, p11_2024, p12s_2024):
availability = {
"P10": not p10_2024.empty,
"P11": not p11_2024.empty,
"BASE": not base_2024.empty,
"P12_STD": not p12s_2024.empty
}
order = [VAR_FINAL_DEF, "P11", "BASE", "P12_STD"]
chosen = next((v for v in order if availability.get(v, False)), None)
if chosen is None:
log13("AVISO: no hay ninguna variante disponible (ni BASE). El paso continuará con métricas vacías.")
else:
log13(f"Variante final seleccionada para métricas (según diagnóstico): {chosen}")
return chosen, availability
chosen_var, availability = _select_variant_availability_for_metrics(base_2024, p10_2024, p11_2024, p12s_2024)
# Métricas globales por variante disponible
metric_rows = []
var_map = {"BASE": base_2024, "P10": p10_2024, "P11": p11_2024, "P12_STD": p12s_2024, "P12_ACTIVE": p12a_2024}
for vname, vdf in var_map.items():
if vdf.empty:
continue
m = _merge_real(vdf) if not real_2024.empty else pd.DataFrame()
if not m.empty:
met = _metrics_block(m, "yhat", "y_real", scale_mase=mase_scale)
else:
met = {"MAE":np.nan,"RMSE":np.nan,"MAPE%":np.nan,"sMAPE%":np.nan,"WAPE%":np.nan,"Bias":np.nan,"MASE":np.nan,"WASE":np.nan}
if not vdf.empty and not real_2024.empty:
merged = vdf.merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA"]], on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="left")
cobertura = 100.0 * merged["FECHA_y"].notna().mean() if "FECHA_y" in merged.columns else np.nan
else:
cobertura = np.nan
row = {"Variante": vname, **met, "Cobertura%": cobertura}
metric_rows.append(row)
df_metrics_global = pd.DataFrame(metric_rows)
_save_csv(df_metrics_global, os.path.join(CARP_MET, "metricas_globales_2024.csv"))
# Métricas por FM_COST_TYPE × PAIS
df_metrics_seg = pd.DataFrame()
if not real_2024.empty and not df_dim.empty:
dim = df_dim.copy()
colmap_dim = {}
for c in dim.columns:
cu = str(c).upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap_dim[c] = "ID_BUILDING"
elif cu in ("PAIS","COUNTRY","COUNTRY_DEF"):
colmap_dim[c] = "PAIS"
elif cu in ("REGION","REGIÓN"):
colmap_dim[c] = "REGION"
elif cu in ("STATUS","ESTADO"):
colmap_dim[c] = "STATUS"
if colmap_dim:
dim = dim.rename(columns=colmap_dim)
for vname, vdf in var_map.items():
if vdf.empty:
continue
merged = vdf.merge(dim[["ID_BUILDING","PAIS"]] if "PAIS" in dim.columns else vdf[["ID_BUILDING"]].assign(PAIS="SIN_PAIS"),
on="ID_BUILDING", how="left")
merged = merged.merge(real_2024[["ID_BUILDING","FM_COST_TYPE","FECHA","y_real"]],
on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
if merged.empty:
continue
rows = []
grp_cols = ["FM_COST_TYPE","PAIS"] if "PAIS" in merged.columns else ["FM_COST_TYPE"]
for keys, g in merged.groupby(grp_cols):
fmc = keys[0] if isinstance(keys, tuple) else keys
pais = (keys[1] if isinstance(keys, tuple) and len(keys)>1 else None)
met = _metrics_block(g, "yhat","y_real", scale_mase=mase_scale)
base = {"Variante": vname, "FM_COST_TYPE":fmc}
if pais is not None:
base["PAIS"]=pais
rows.append({**base, **met})
if rows:
segi = pd.DataFrame(rows)
df_metrics_seg = pd.concat([df_metrics_seg, segi], ignore_index=True)
else:
log13("AVISO: sin reales 2024 no generamos métricas segmentadas.")
_save_csv(df_metrics_seg, os.path.join(CARP_MET, "metricas_por_fmcost_pais_2024.csv"))
# =========================================================
# SUBSET ESTABLE 2024 — Carga robusta y separación portfolio/segmento
# =========================================================
# Si ya existen los ficheros separados, los leemos; si no, partimos del mixto y los generamos
df_subset_port, df_subset_segm = pd.DataFrame(), pd.DataFrame()
if os.path.exists(P_SUBSET_PORT) or os.path.exists(P_SUBSET_SEGM):
if os.path.exists(P_SUBSET_PORT):
df_subset_port = _read_csv_soft(P_SUBSET_PORT, candidate_date_cols=["FECHA"])
if os.path.exists(P_SUBSET_SEGM):
df_subset_segm = _read_csv_soft(P_SUBSET_SEGM, candidate_date_cols=["FECHA"])
else:
# Leemos el mixto y separamos
df_subset_port, df_subset_segm = _load_metrics_subset_estable_mixto(P_SUBSET_P9)
# Guardamos en ENTRADA (saneados para futuros pasos)
if not df_subset_port.empty:
df_subset_port.to_csv(P_SUBSET_PORT, sep=CSV_SEP, index=False)
log13(f"Normalizado y guardado: {P_SUBSET_PORT} ({len(df_subset_port)} filas).")
if not df_subset_segm.empty:
df_subset_segm.to_csv(P_SUBSET_SEGM, sep=CSV_SEP, index=False)
log13(f"Normalizado y guardado: {P_SUBSET_SEGM} ({len(df_subset_segm)} filas).")
# Copiamos/guardamos también en el ENTREGABLE (carpeta METRICAS del paquete)
OUT_SUBSET_PORT = os.path.join(CARP_MET, "metrics_subset_estable_portfolio.csv")
OUT_SUBSET_SEGM = os.path.join(CARP_MET, "metrics_subset_estable_segmento.csv")
_save_csv(df_subset_port, OUT_SUBSET_PORT)
_save_csv(df_subset_segm, OUT_SUBSET_SEGM)
# Para compatibilidad hacia atrás, mantenemos un "subconjunto_estable_2024.csv" mínimo (usamos portfolio)
_save_csv(df_subset_port.copy(), os.path.join(CARP_MET, "subconjunto_estable_2024.csv"))
# Avisos útiles
if df_subset_segm.empty:
log13("AVISO: metrics_subset_estable_segmento está vacío. Revisa Paso 9 (export de segmentos).")
# Top-N (intentamos usar el subset más informativo disponible)
df_pairs_for_top = None
# Preferimos datos con columna WAPE(%) o WAPE a nivel detallado; si no los hay, recomputamos desde predicciones
for cand in [df_subset_segm, df_subset_port]:
if not cand.empty and (("WAPE%" in cand.columns) or ("WAPE" in cand.columns)):
df_pairs_for_top = cand.copy()
break
if df_pairs_for_top is None and (chosen_var is not None and not real_2024.empty):
chosen_df = var_map.get(chosen_var, pd.DataFrame())
merged = _merge_real(chosen_df)
rows = []
for (bid, fmc), g in merged.groupby(["ID_BUILDING","FM_COST_TYPE"]):
met = _metrics_block(g, "yhat","y_real", scale_mase=mase_scale if not mase_scale.empty else None)
rows.append({"ID_BUILDING":bid,"FM_COST_TYPE":fmc, **met})
df_pairs_for_top = pd.DataFrame(rows)
if df_pairs_for_top is not None and not df_pairs_for_top.empty:
# Elegimos la columna de WAPE disponible
wape_col = "WAPE%" if "WAPE%" in df_pairs_for_top.columns else ("WAPE" if "WAPE" in df_pairs_for_top.columns else None)
if wape_col:
# Si WAPE está en [0,1], lo pasamos a %
w = pd.to_numeric(df_pairs_for_top[wape_col], errors="coerce")
if wape_col == "WAPE" and w.notna().max() <= 1.0:
df_pairs_for_top["WAPE%"] = 100.0 * w
wape_col = "WAPE%"
top_mej = df_pairs_for_top.sort_values(wape_col, ascending=True).head(N_TOP)
top_peor= df_pairs_for_top.sort_values(wape_col, ascending=False).head(N_TOP)
_save_csv(top_mej, os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(top_peor, os.path.join(CARP_MET, "top_peores_wape.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_peores_wape.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_mejores_wape.csv"))
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "top_peores_wape.csv"))
# Outliers por delta P12
top_out = pd.DataFrame()
if not df_comp.empty:
d = df_comp.copy()
for cand in ["delta_p12std","delta_p12","delta_p11","delta_p10"]:
if cand in d.columns:
d["abs_delta"] = pd.to_numeric(d[cand], errors="coerce").abs()
top_out = d.sort_values("abs_delta", ascending=False).head(N_TOP)
break
_save_csv(top_out, os.path.join(CARP_MET, "top_outliers_delta_p12.csv"))
# =================
# 7) FIGURAS
# =================
if chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
m = _merge_real(chosen_df)
if not m.empty:
by_m = (m.groupby("FECHA", as_index=False)
.apply(lambda g: pd.Series({"WAPE%": (100.0 * (g["yhat"]-g["y_real"]).abs().sum() /
g["y_real"].abs().sum()) if g["y_real"].abs().sum()!=0 else np.nan})))
plt.figure(figsize=(10,5))
plt.plot(by_m["FECHA"], by_m["WAPE%"])
plt.title(f"WAPE mensual portfolio — {chosen_var} (2024)")
plt.xlabel("Mes"); plt.ylabel("WAPE %")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "wape_portfolio_mensual.png"), dpi=150)
plt.close()
else:
log13("AVISO: sin merge real no generamos figura wape_portfolio_mensual.")
else:
log13("AVISO: sin reales 2024 no generamos figura wape_portfolio_mensual.")
if chosen_var is not None and not real_2024.empty:
chosen_df = var_map.get(chosen_var, pd.DataFrame())
merged = chosen_df.merge(real_2024, on=["ID_BUILDING","FM_COST_TYPE","FECHA"], how="inner")
if not merged.empty:
w_rows = []
for fmc, g in merged.groupby("FM_COST_TYPE"):
den = g["y_real"].abs().sum()
wape = 100.0 * (g["yhat"]-g["y_real"]).abs().sum() / den if den!=0 else np.nan
w_rows.append({"FM_COST_TYPE":fmc,"WAPE%":wape})
wdf = pd.DataFrame(w_rows).sort_values("WAPE%", ascending=False)
plt.figure(figsize=(10,5))
plt.bar(wdf["FM_COST_TYPE"], wdf["WAPE%"])
plt.xticks(rotation=45, ha="right")
plt.title(f"WAPE por FM_COST_TYPE — {chosen_var} (2024)")
plt.ylabel("WAPE %")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "wape_por_fmcost.png"), dpi=150)
plt.close()
else:
log13("AVISO: sin merge real no generamos figura wape_por_fmcost.")
else:
log13("AVISO: sin reales 2024 no generamos figura wape_por_fmcost.")
if not df_comp.empty:
d = df_comp.copy()
delta_cols = [c for c in ["delta_p10","delta_p11","delta_p12","delta_p12std","delta_p12act"] if c in d.columns]
if delta_cols:
plt.figure(figsize=(9,6))
plt.boxplot([pd.to_numeric(d[c], errors="coerce").dropna().values for c in delta_cols], labels=delta_cols, showfliers=False)
plt.axhline(0, color="grey", linestyle=":")
plt.title("Distribución de deltas bottom (reconc - base)")
plt.ylabel("Delta")
plt.tight_layout()
plt.savefig(os.path.join(CARP_FIG, "boxplot_deltas_reconciliacion.png"), dpi=150)
plt.close()
else:
log13("AVISO: no fue posible generar boxplot de deltas (faltan columnas delta_*).")
else:
log13("AVISO: comparativa bottom ausente; no generamos boxplot de deltas.")
# =================
# 8) INFORMES
# =================
resumen_lines = [
"# Resumen ejecutivo — Paso 13",
"",
"Este documento recoge las cifras clave de desempeño 2024, el subconjunto estable, oportunidades rápidas (quick-wins) y las referencias a los principales artefactos del entregable.",
"",
"## 1) Cinco cifras clave (2024)",
]
if not df_metrics_global.empty:
row = None
if chosen_var is not None:
rows = df_metrics_global[df_metrics_global["Variante"].eq(chosen_var)]
if not rows.empty: row = rows.iloc[0]
if row is None:
row = df_metrics_global.iloc[0]
resumen_lines += [
f"- **Variante**: {row['Variante']}",
f"- **WAPE**: {row['WAPE%']:.2f}%",
f"- **sMAPE**: {row['sMAPE%']:.2f}%",
f"- **MASE**: {row['MASE']:.4f}",
f"- **Cobertura**: {row['Cobertura%']:.2f}%"
]
else:
resumen_lines += ["- No disponemos de métricas pobladas (faltan reales o variantes)."]
resumen_lines += [
"",
"## 2) Subconjunto estable (agregado de métricas)",
f"- Portfolio: `METRICAS/metrics_subset_estable_portfolio.csv` (filas: {len(df_subset_port)})",
f"- Segmento: `METRICAS/metrics_subset_estable_segmento.csv` (filas: {len(df_subset_segm)})",
"- (Compat.) `METRICAS/subconjunto_estable_2024.csv` (alias de portfolio)",
"",
"## 3) Quick-wins propuestos",
"- Intermitentes/TSB: priorizar pares con alta esporadicidad donde el TSB mejora el error.",
"- Combos/Ensemble: consolidar rutas con mejor MASE/WAPE por segmento.",
"- Outliers (mod_3): revisar top outliers en `METRICAS/top_outliers_delta_p12.csv` y considerar winsorización.",
"",
"## 4) Enlaces a outputs del entregable",
"- `METRICAS/metricas_globales_2024.csv`",
"- `METRICAS/metricas_por_fmcost_pais_2024.csv`",
"- `FIGURAS/wape_portfolio_mensual.png`",
"- `FIGURAS/wape_por_fmcost.png`",
"- `FIGURAS/boxplot_deltas_reconciliacion.png`",
"",
"## 5) Checklist de verificación",
"- Predicciones disponibles para todas las series o fallback documentado.",
"- Ficheros METRICAS completos generados.",
"- Nombres y rutas coherentes con el estándar del proyecto.",
]
with open(os.path.join(CARP_INF, "resumen_ejecutivo.md"), "w", encoding="utf-8") as f:
f.write("\n".join(resumen_lines))
check_lines = [
"# Checklist de verificación — Paso 13",
"",
"- [ ] Predicciones 2024 presentes (BASE/P10/P11/P12_STD) o placeholders creados",
"- [ ] Predicciones 2025 presentes (BASE/P11/P12_STD) o placeholders creados",
"- [ ] Métricas globales 2024 generadas",
"- [ ] Métricas por FM_COST_TYPE × PAIS generadas",
"- [ ] Subset estable (portfolio y segmento) generados",
"- [ ] Top-N (mejores/peores WAPE) generados",
"- [ ] Outliers delta P12 disponibles",
"- [ ] Figuras generadas (si hay datos)",
"- [ ] Bundle de inferencia listo (manifest.json, predict.py, matrices si aplica)",
]
with open(os.path.join(CARP_INF, "checklist_verificacion.md"), "w", encoding="utf-8") as f:
f.write("\n".join(check_lines))
# =========================================
# 9) EXPORT DE INFERENCIA PARA LA WEB
# =========================================
# 9.1 Resultados 2024 -> ENTREGABLE/RESULTADOS
base_2024_export = base_2024.copy() if not base_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
p10_2024_export = p10_2024.copy() if not p10_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
p11_2024_export = p11_2024.copy() if not p11_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
p12s_2024_export = p12s_2024.copy() if not p12s_2024.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
_save_csv(base_2024_export, os.path.join(CARP_RES, "preds_base_2024.csv"))
_save_csv(p10_2024_export, os.path.join(CARP_RES, "preds_reconciliadas_2024_P10.csv"))
_save_csv(p11_2024_export, os.path.join(CARP_RES, "preds_reconciliadas_2024_P11.csv"))
_save_csv(p12s_2024_export, os.path.join(CARP_RES, "preds_reconciliadas_2024_P12STD.csv"))
# 9.2 Resultados 2025 (leemos si existen; si no, placeholders)
base_2025_raw = _read_csv_soft(P_BASE_2025, DATE_CANDS); base_2025_df, base25_pred = _norm_unpack(base_2025_raw, None)
base_2025 = _ensure_contract(base_2025_df, base25_pred, 2025)
p11_2025_raw = _read_csv_soft(P_P11_2025, DATE_CANDS); p11_2025_df, p11_25_pred = _norm_unpack(p11_2025_raw, "p11")
p11_2025 = _ensure_contract(p11_2025_df, p11_25_pred or "yhat_reconc", 2025)
p12_2025_raw = _read_csv_soft(P_P12_2025, DATE_CANDS); p12_2025_df, p12_25_pred = _norm_unpack(p12_2025_raw, "p12std")
p12_2025 = _ensure_contract(p12_2025_df, p12_25_pred or "yhat_reconc", 2025)
_save_csv(base_2025 if not base_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_base_2025.csv"))
_save_csv(p11_2025 if not p11_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2025_P11.csv"))
_save_csv(p12_2025 if not p12_2025.empty else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]),
os.path.join(CARP_RES, "preds_reconciliadas_2025_P12STD.csv"))
# 9.3 Ruteo por par con reglas (país/tipo) y degradación por disponibilidad
def _availability_flags(dfs_by_variant, year):
rows = []
for variant, dfv in dfs_by_variant.items():
if dfv.empty:
continue
g = dfv.groupby(["ID_BUILDING","FM_COST_TYPE"], as_index=False).size()
g["year"] = year
g["variant"] = variant
g["available"] = 1
rows.append(g[["ID_BUILDING","FM_COST_TYPE","year","variant","available"]])
return pd.concat(rows, ignore_index=True) if rows else pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","year","variant","available"])
avail_2024 = _availability_flags({"BASE":base_2024_export, "P10":p10_2024_export, "P11":p11_2024_export, "P12_STD":p12s_2024_export}, 2024)
avail_2025 = _availability_flags({"BASE":base_2025, "P11":p11_2025, "P12_STD":p12_2025}, 2025)
avail_all = pd.concat([avail_2024, avail_2025], ignore_index=True)
# Reglas por país/tipo
PREF_PAIS = {
"COLOMBIA":"P10",
"ESPAÑA":"P10",
"MEXICO":"P11", "MÉXICO":"P11",
"PANAMÁ":"P11","PANAMA":"P11",
"PERÚ":"P11","PERU":"P11",
"COSTA RICA":None,
"REP. DOMINICANA":"P10", "REPUBLICA DOMINICANA":"P10"
}
PREF_FMCOST = { "SERVICIOS CTTO./EXTRA":"P11" }
DEFAULT_VARIANT = "P10"
def _prefer_variant_for_pair(pais, fmc):
v_pais = PREF_PAIS.get(str(pais).upper().strip()) if pd.notna(pais) else None
v_fmc = PREF_FMCOST.get(str(fmc).upper().strip()) if pd.notna(fmc) else None
return v_fmc or v_pais or DEFAULT_VARIANT
# Enriquecemos con PAIS desde dim
dim_min = df_dim.copy()
if not dim_min.empty:
colmap_dim = {}
for c in dim_min.columns:
cu = str(c).upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap_dim[c] = "ID_BUILDING"
elif cu in ("PAIS","COUNTRY","COUNTRY_DEF"):
colmap_dim[c] = "PAIS"
if colmap_dim:
dim_min = dim_min.rename(columns=colmap_dim)
dim_min = dim_min[["ID_BUILDING","PAIS"]].drop_duplicates() if "ID_BUILDING" in dim_min.columns else pd.DataFrame(columns=["ID_BUILDING","PAIS"])
else:
dim_min = pd.DataFrame(columns=["ID_BUILDING","PAIS"])
pairs_year = avail_all[["ID_BUILDING","FM_COST_TYPE","year"]].drop_duplicates()
pairs_year = pairs_year.merge(dim_min, on="ID_BUILDING", how="left")
decisions = []
for _, r in pairs_year.iterrows():
pref = _prefer_variant_for_pair(r.get("PAIS"), r.get("FM_COST_TYPE"))
if r["year"] == 2024:
degrade_order = [pref, "P10","P11","BASE","P12_STD"] if pref in ["P10","P11","BASE","P12_STD"] else ["P10","P11","BASE","P12_STD"]
else:
degrade_order = [pref, "P11","BASE","P12_STD"] if pref in ["P11","BASE","P12_STD"] else ["P11","BASE","P12_STD"]
degrade = [v for v in degrade_order if v is not None]
seen = set(); degrade = [x for x in degrade if not (x in seen or seen.add(x))]
chosen = None
for v in degrade:
ok = not avail_all[(avail_all["ID_BUILDING"].eq(r["ID_BUILDING"])) &
(avail_all["FM_COST_TYPE"].eq(r["FM_COST_TYPE"])) &
(avail_all["year"].eq(r["year"])) &
(avail_all["variant"].eq(v))].empty
if ok:
chosen = v
break
decisions.append({
"ID_BUILDING": r["ID_BUILDING"],
"FM_COST_TYPE": r["FM_COST_TYPE"],
"PAIS": r.get("PAIS"),
"year": r["year"],
"variant_preferida": pref if pref is not None else "SIN_REGLA",
"variant_final": chosen or "NO_DISPONIBLE"
})
ruteo = pd.DataFrame(decisions)
_save_csv(ruteo, os.path.join(CARP_MET, "ruteo_por_par.csv"))
# 9.4 Manifest del bundle (incluye los nuevos subset)
manifest = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"base_path": RUTA_ENTREGABLE,
"results": {
"2024": {
"BASE": "RESULTADOS/preds_base_2024.csv",
"P10": "RESULTADOS/preds_reconciliadas_2024_P10.csv",
"P11": "RESULTADOS/preds_reconciliadas_2024_P11.csv",
"P12_STD": "RESULTADOS/preds_reconciliadas_2024_P12STD.csv"
},
"2025": {
"BASE": "RESULTADOS/preds_base_2025.csv",
"P11": "RESULTADOS/preds_reconciliadas_2025_P11.csv",
"P12_STD": "RESULTADOS/preds_reconciliadas_2025_P12STD.csv"
}
},
"metrics": {
"global_2024": "METRICAS/metricas_globales_2024.csv",
"seg_2024": "METRICAS/metricas_por_fmcost_pais_2024.csv",
"subset_portfolio_2024": "METRICAS/metrics_subset_estable_portfolio.csv",
"subset_segmento_2024": "METRICAS/metrics_subset_estable_segmento.csv",
"subset_legacy_2024": "METRICAS/subconjunto_estable_2024.csv",
"ruteo": "METRICAS/ruteo_por_par.csv"
},
"figures": {
"wape_mensual": "FIGURAS/wape_portfolio_mensual.png",
"wape_fmcost": "FIGURAS/wape_por_fmcost.png",
"box_deltas": "FIGURAS/boxplot_deltas_reconciliacion.png"
}
}
with open(os.path.join(CARP_BUNDLE, "manifest.json"), "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
# 9.5 Copias útiles al entregable (dimensiones e IPC) con nombres españolizados
if not df_dim.empty:
dim_out = df_dim.copy()
colmap_dim = {}
for c in dim_out.columns:
cu = str(c).upper()
if cu in ("ID_BUILDING","IDBUILDING","BUILDING_ID","ID_EDIFICIO"):
colmap_dim[c] = "ID_BUILDING"
elif cu in ("PAIS","COUNTRY","COUNTRY_DEF"):
colmap_dim[c] = "PAIS"
elif cu in ("REGION","REGIÓN"):
colmap_dim[c] = "REGION"
elif cu in ("STATUS","ESTADO"):
colmap_dim[c] = "STATUS"
if colmap_dim:
dim_out = dim_out.rename(columns=colmap_dim)
_save_csv(dim_out, os.path.join(CARP_MET, "dim_edificios.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "dim_edificios.csv"))
if not df_ipc.empty:
ipcout = df_ipc.copy()
colmap_ipc = {}
up = {c.upper(): c for c in ipcout.columns}
if "PAIS" not in up:
for k in ("COUNTRY","COUNTRY_DEF"):
if k in up: colmap_ipc[up[k]] = "PAIS"; break
if "FECHA" not in up:
for k in ("DATE","DS","PERIOD","MES","MONTH"):
if k in up: colmap_ipc[up[k]] = "FECHA"; break
if "IPC_YOY_PCT" not in up:
for k in ("IPC","IPC_YOY","YOY","VAR_INTERANUAL","IPC_ANUAL_%"):
if k in up: colmap_ipc[up[k]] = "IPC_YOY_PCT"; break
if colmap_ipc:
ipcout = ipcout.rename(columns=colmap_ipc)
if "FECHA" in ipcout.columns:
ipcout["FECHA"] = pd.to_datetime(ipcout["FECHA"], errors="coerce").dt.to_period("M").dt.to_timestamp()
_save_csv(ipcout, os.path.join(CARP_MET, "ipc_mensual.csv"))
else:
_save_csv(pd.DataFrame(), os.path.join(CARP_MET, "ipc_mensual.csv"))
if not df_ipc25.empty:
ip25 = df_ipc25.copy()
if "FECHA" in ip25.columns:
ip25["FECHA"] = pd.to_datetime(ip25["FECHA"], errors="coerce")
_save_csv(ip25, os.path.join(CARP_MET, "ipc_mensual_2025.csv"))
else:
log13("AVISO: no guardamos ipc_mensual_2025.csv porque no lo pudimos construir ni leer.")
# 9.6 predict.py (para servir desde la web)
predict_py = f'''# predict.py — lector de predicciones 2024/2025 para la web
# Devolvemos (ID_BUILDING, FM_COST_TYPE, FECHA, yhat)
import os, pandas as pd
CSV_SEP = "{CSV_SEP}"
def _auto_base():
here = os.path.abspath(os.path.dirname(__file__))
return os.path.abspath(os.path.join(here, ".."))
def _read(path):
if not os.path.exists(path):
return pd.DataFrame(columns=["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"])
hdr = pd.read_csv(path, sep=CSV_SEP, nrows=0)
parse_dates = ["FECHA"] if "FECHA" in hdr.columns else None
df = pd.read_csv(path, sep=CSV_SEP, parse_dates=parse_dates)
df.columns = [str(c).strip() for c in df.columns]
need = ["ID_BUILDING","FM_COST_TYPE","FECHA","yhat"]
for n in need:
if n not in df.columns:
df[n] = pd.Series(dtype="float64")
return df[need]
def predict(ids, year=2025, variant="P10", ruta_base="__AUTO__"):
"""
ids: lista de ID_BUILDING
year: 2024 o 2025
variant: 'P10' | 'P11' | 'P12_STD' | 'BASE'
ruta_base: si '__AUTO__', resolvemos base a partir de este paquete
"""
if ruta_base == "__AUTO__":
ruta_base = _auto_base()
if year == 2024:
files = {{
"BASE": os.path.join(ruta_base, "RESULTADOS", "preds_base_2024.csv"),
"P10": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2024_P10.csv"),
"P11": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2024_P11.csv"),
"P12_STD": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2024_P12STD.csv")
}}
else:
files = {{
"BASE": os.path.join(ruta_base, "RESULTADOS", "preds_base_2025.csv"),
"P11": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2025_P11.csv"),
"P12_STD": os.path.join(ruta_base, "RESULTADOS", "preds_reconciliadas_2025_P12STD.csv")
}}
# Si piden P10 en 2025 y no existe, degradamos a P11, luego BASE, luego P12_STD
if year == 2025 and variant.upper() == "P10":
variant = "P11"
path = files.get(variant.upper(), files.get("P11", files.get("BASE")))
df = _read(path)
if ids is not None and len(ids)>0 and "ID_BUILDING" in df.columns:
sids = pd.Series(ids).astype("Int64") if hasattr(pd.Series(ids), "astype") else pd.Series(ids)
df = df[df["ID_BUILDING"].astype("Int64").isin(sids)]
return df
'''
with open(os.path.join(CARP_BUNDLE, "predict.py"), "w", encoding="utf-8") as f:
f.write(predict_py)
# (Opcional) Matrices A / W si existieran en entrada (las traemos al bundle)
A_CAND = os.path.join(RUTA_ENTRADA, "METRICAS", "A_matriz.npz")
W_CAND = os.path.join(RUTA_ENTRADA, "METRICAS", "W_diag.npy")
if os.path.exists(A_CAND):
import shutil
shutil.copy(A_CAND, os.path.join(CARP_BUNDLE, "A_matriz.npz"))
if os.path.exists(W_CAND):
import shutil
shutil.copy(W_CAND, os.path.join(CARP_BUNDLE, "W_diag.npy"))
log13("Paso 13 finalizado.")
[2025-09-28T07:27:55] Iniciamos Paso 13 — Paquete de entrega + Hoja de ruta (alineado con diagnóstico). [2025-09-28T07:28:04] FALTA METRICAS/dim_edificios.csv. Seguimos sin romper. [2025-09-28T07:28:07] Guardado informe de esquema: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/INFORMES/esquema_entradas.md [2025-09-28T07:28:08] Variante final seleccionada para métricas (según diagnóstico): P10 [2025-09-28T07:28:08] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metricas_globales_2024.csv (5 filas). [2025-09-28T07:28:11] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metricas_por_fmcost_pais_2024.csv (215 filas). [2025-09-28T07:28:12] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metrics_subset_estable_portfolio.csv (12 filas). [2025-09-28T07:28:12] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/metrics_subset_estable_segmento.csv (1560 filas). [2025-09-28T07:28:12] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/subconjunto_estable_2024.csv (12 filas). [2025-09-28T07:28:13] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_mejores_wape.csv (50 filas). [2025-09-28T07:28:13] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_peores_wape.csv (50 filas). [2025-09-28T07:28:13] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/top_outliers_delta_p12.csv (50 filas). [2025-09-28T07:28:17] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_base_2024.csv (29148 filas). [2025-09-28T07:28:18] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2024_P10.csv (29148 filas). [2025-09-28T07:28:19] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2024_P11.csv (29148 filas). [2025-09-28T07:28:20] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2024_P12STD.csv (29148 filas). [2025-09-28T07:28:20] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_base_2025.csv (0 filas). [2025-09-28T07:28:21] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2025_P11.csv (0 filas). [2025-09-28T07:28:21] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/RESULTADOS/preds_reconciliadas_2025_P12STD.csv (0 filas). [2025-09-28T07:28:40] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/ruteo_por_par.csv (2429 filas). [2025-09-28T07:28:41] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/dim_edificios.csv (1650 filas). [2025-09-28T07:28:41] Guardado CSV: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/ipc_mensual.csv (12 filas). [2025-09-28T07:28:41] AVISO: no guardamos ipc_mensual_2025.csv porque no lo pudimos construir ni leer. [2025-09-28T07:28:41] Paso 13 finalizado.
Interpretación¶
Con este paso tenemos una visión clara del Paquete de entrega + Hoja de ruta seguida en esta 3a y última estrategia de esta primera entrega.
Con su ejecución obtenemos:
- Flujo general
El paso se completó sin romper, incluso con ausencias iniciales como dim_edificios.csv (que luego sí se generó correctamente con 1650 filas).
Se generó un entregable completo con informes, métricas y predicciones para 2024 (plenas) y ya preparado para las del 2025, que en esta versión se dejan vacíos.
- Métricas
Globales 2024: 5 filas -> resumen consolidado del performance.
Por FMCost y País: 215 filas -> desgloses relevantes.
Subconjuntos estables: portfolio (12 filas) y segmento (1560 filas).
Top 50: mejores, peores y outliers delta p12 -> insumos clave para diagnóstico.
Ruteo por par: 2429 filas -> confirmamos el tamaño del universo modelado.
- Predicciones
2024:
preds_base (29.148 filas) y reconciliadas bajo políticas P10 (final), P11 y P12STD -> diversidad de escenarios alineados con diagnóstico.
2025:
Archivos creados pero vacíos (0 filas). Esto mantiene el contrato estable, aunque todavía no hay datos para inferir.
- Catálogo de edificios
dim_edificios.csv finalmente guardado (1650 filas).
Esto nos asegura la consistencia con el resto de outputs.
- IPC
ipc_mensual.csv generado para 2024 (12 filas).
Aviso: no se nos ha guardado ipc_mensual_2025.csv por falta de datos/fuentes -> se mantiene coherencia de la salida (sin romper el flujo del paso 13).
Conclusión:
Cumplimos con la función de empaquetado final y hoja de ruta para presentar al equipo de FM.
Entregamos de manera completa la estructura obtenida con las previsiones de 2024 con previsiones, métricas y variantes.
Añadimos un guiño a las previsiones de 2025, queda listo para cuando haya datos si nos confirman que les interesa seguir con el proyecto.
El flujo implementado nos demuestra robustez, cuando no están los ficheros (IPC 2025, catálogo al inicio), no se nos rompe, sino que nos documenta en avisos y sigue.
# =========================================
# 10) VALIDACIÓN FINAL DE ARTEFACTOS CLAVE
# =========================================
log13("=== VALIDACIÓN FINAL — Resumen de artefactos clave ===")
def _read_out_soft(path):
if not os.path.exists(path):
log13(f"[VALID] NO ENCONTRADO -> {path}")
return pd.DataFrame()
try:
hdr = pd.read_csv(path, sep=CSV_SEP, nrows=0)
parse_dates = ["FECHA"] if "FECHA" in hdr.columns else None
df = pd.read_csv(path, sep=CSV_SEP, parse_dates=parse_dates)
df.columns = [str(c).strip() for c in df.columns]
return df
except Exception as e:
log13(f"[VALID][ERROR] Leyendo {path}: {e}")
return pd.DataFrame()
def _summary_df(tag, df: pd.DataFrame, extra_checks=None):
n, cols = (len(df), df.columns.tolist()) if not df.empty else (0, [])
msg = f"[VALID] {tag}: filas={n}, columnas={len(cols)}"
if "FECHA" in cols and not df.empty and pd.api.types.is_datetime64_any_dtype(df["FECHA"]):
try:
rmin, rmax = df["FECHA"].min(), df["FECHA"].max()
msg += f", FECHA[{rmin.date()} .. {rmax.date()}]"
except Exception:
pass
log13(msg)
if n and extra_checks:
extra_checks(df)
def _check_wape(df):
for c in ["WAPE%","WAPE"]:
if c in df.columns:
w = pd.to_numeric(df[c], errors="coerce")
if w.notna().any():
mx = w.max()
if c == "WAPE" and mx <= 1.0:
log13(f"[VALID][OK] {c} parece fracción [0..1].")
elif c == "WAPE" and mx > 1.0:
log13(f"[VALID][WARN] {c} parece porcentaje (max={mx:.2f}); considera renombrar a WAPE% o dividir por 100.")
if c == "WAPE%" and mx <= 1.0:
log13(f"[VALID][WARN] {c} parece fracción (max={mx:.3f}); ¿faltó *100?")
break
def _check_scope_port(df):
if "scope" in df.columns:
vals = df["scope"].astype(str).str.upper().unique().tolist()
ok = "PORTFOLIO" in [v.upper() for v in vals]
log13(f"[VALID][{ 'OK' if ok else 'WARN' }] scope contiene PORTFOLIO. Valores: {vals}")
def _check_scope_segm(df):
need = {"DIM_NAME","DIM_VALUE","scope"}
miss = [c for c in need if c not in df.columns]
if miss:
log13(f"[VALID][WARN] columnas esperado segmento faltantes: {miss}")
vals = df["scope"].astype(str).str.upper().unique().tolist() if "scope" in df.columns else []
ok = "SEGMENTO" in [v.upper() for v in vals]
log13(f"[VALID][{ 'OK' if ok else 'WARN' }] scope contiene SEGMENTO. Valores: {vals}")
# Rutas a validar (salidas del entregable)
artefactos = {
"preds_base_2024" : os.path.join(CARP_RES, "preds_base_2024.csv"),
"preds_p10_2024" : os.path.join(CARP_RES, "preds_reconciliadas_2024_P10.csv"),
"preds_p11_2024" : os.path.join(CARP_RES, "preds_reconciliadas_2024_P11.csv"),
"preds_p12std_2024" : os.path.join(CARP_RES, "preds_reconciliadas_2024_P12STD.csv"),
"metricas_globales_2024" : os.path.join(CARP_MET, "metricas_globales_2024.csv"),
"metricas_seg_2024" : os.path.join(CARP_MET, "metricas_por_fmcost_pais_2024.csv"),
"subset_portfolio_2024" : os.path.join(CARP_MET, "metrics_subset_estable_portfolio.csv"),
"subset_segmento_2024" : os.path.join(CARP_MET, "metrics_subset_estable_segmento.csv"),
"subset_legacy_2024" : os.path.join(CARP_MET, "subconjunto_estable_2024.csv"),
"top_mejores_wape" : os.path.join(CARP_MET, "top_mejores_wape.csv"),
"top_peores_wape" : os.path.join(CARP_MET, "top_peores_wape.csv"),
"outliers_delta_p12" : os.path.join(CARP_MET, "top_outliers_delta_p12.csv"),
"ipc_mensual" : os.path.join(CARP_MET, "ipc_mensual.csv"),
"ipc_mensual_2025" : os.path.join(CARP_MET, "ipc_mensual_2025.csv"),
"dim_edificios" : os.path.join(CARP_MET, "dim_edificios.csv"),
}
for tag, path in artefactos.items():
dfv = _read_out_soft(path)
extras = None
if tag == "subset_portfolio_2024":
extras = lambda d: (_check_scope_port(d), _check_wape(d))
elif tag == "subset_segmento_2024":
extras = lambda d: (_check_scope_segm(d), _check_wape(d))
elif tag in {"top_mejores_wape","top_peores_wape"}:
extras = _check_wape
_summary_df(tag, dfv, extras)
log13("=== VALIDACIÓN FINAL — Completada ===")
[2025-09-28T07:51:13] === VALIDACIÓN FINAL — Resumen de artefactos clave === [2025-09-28T07:51:14] [VALID] preds_base_2024: filas=29148, columnas=4, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:14] [VALID] preds_p10_2024: filas=29148, columnas=4, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:15] [VALID] preds_p11_2024: filas=29148, columnas=4, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:15] [VALID] preds_p12std_2024: filas=29148, columnas=4, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:15] [VALID] metricas_globales_2024: filas=5, columnas=10 [2025-09-28T07:51:16] [VALID] metricas_seg_2024: filas=215, columnas=11 [2025-09-28T07:51:16] [VALID] subset_portfolio_2024: filas=12, columnas=9, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:16] [VALID][OK] scope contiene PORTFOLIO. Valores: ['PORTFOLIO'] [2025-09-28T07:51:16] [VALID][OK] WAPE parece fracción [0..1]. [2025-09-28T07:51:16] [VALID] subset_segmento_2024: filas=1560, columnas=12, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:16] [VALID][OK] scope contiene SEGMENTO. Valores: ['SEGMENTO'] [2025-09-28T07:51:16] [VALID][WARN] WAPE parece porcentaje (max=169462000000.00); considera renombrar a WAPE% o dividir por 100. [2025-09-28T07:51:16] [VALID] subset_legacy_2024: filas=12, columnas=9, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:16] [VALID] top_mejores_wape: filas=50, columnas=13, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:16] [VALID] top_peores_wape: filas=50, columnas=13, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:17] [VALID] outliers_delta_p12: filas=50, columnas=13, FECHA[2024-01-01 .. 2024-12-01] [2025-09-28T07:51:17] [VALID] ipc_mensual: filas=12, columnas=9, FECHA[2023-11-01 .. 2023-11-01] [2025-09-28T07:51:17] [VALID] NO ENCONTRADO -> /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/ESTRATEGIA_3/PRIMER_ENTREGABLE/METRICAS/ipc_mensual_2025.csv [2025-09-28T07:51:17] [VALID] ipc_mensual_2025: filas=0, columnas=0 [2025-09-28T07:51:17] [VALID] dim_edificios: filas=1650, columnas=5 [2025-09-28T07:51:17] === VALIDACIÓN FINAL — Completada ===
Guardamos en HTML¶
# Ruta del notebook .ipynb que vamos a convertir
RUTA_NOTEBOOK = "/content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb"
# Carpeta de salida y nombre del HTML
OUTPUT_DIR = "/content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO"
OUTPUT_HTML = "Neith_notebook_v5_estrategia_3_final.html"
# Aseguramos carpeta de salida (ya en cabecera)
# import os
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Instalamos nbconvert (ya en cabecera)
# !pip install -q "nbconvert>=7.0.0"
# Convertimos notebook a HTML y lo guardamos con el nombre indicado en la carpeta de salida
!jupyter nbconvert --to html "$RUTA_NOTEBOOK" --output "$OUTPUT_HTML" --output-dir "$OUTPUT_DIR"
# Verificamos
salida = os.path.join(OUTPUT_DIR, OUTPUT_HTML)
print("Archivo HTML generado en:", salida, "\nExiste:", os.path.exists(salida))
[NbConvertApp] Converting notebook /content/drive/MyDrive/PROYECTO_NEITH/DOCUMENTOS_PROYECTOS/Neith.ipynb to html [NbConvertApp] WARNING | Alternative text is missing on 60 image(s). [NbConvertApp] Writing 9575851 bytes to /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_estrategia_3_final.html Archivo HTML generado en: /content/drive/MyDrive/PROYECTO_NEITH/OUTPUT_PROYECTO/Neith_notebook_v5_estrategia_3_final.html Existe: True