Cómo estandarizamos los precios mayoristas de SNIIM
Convertimos los 47 reportes del SNIIM (25 esquemas de origen distintos) en un solo modelo canónico: una tabla de hechos, dimensiones compartidas y reglas explícitas de unidad y de calidad de dato.
Generado desde
sniim-extractor/src/core/canonical.py— no editar a mano. Regenerar consniim-extractor/.venv/bin/python scripts/build_standardization_note.py.
Por qué estandarizar
El SNIIM publica 47 reportes independientes: boletines diarios por central de abasto para productos agrícolas, pecuarios y pesqueros. Cada reporte se publica como su propia tabla, con nombres de columna, unidades y supuestos que cambian de un sector a otro y, a veces, dentro del mismo sector.
Sin estandarizar, esas tablas no forman una superficie de análisis: el precio viaja en columnas que se llaman Precio Mín en un boletín, Pmín en otro y Pechuga_Mín($/kg) en un tercero; la misma columna mezcla precios por caja, manojo, pieza o kilogramo; y 0.00 se usa para decir «no reportado», no para decir «gratis».
El modelo canónico resuelve esas diferencias una sola vez, en el pipeline, para que una consulta funcione igual en los tres sectores y cualquier precio pueda compararse por kilogramo.
Antes y después
Cuatro reportes reales, cada uno con su forma de origen, y las filas que produce el pipeline. Los valores son ilustrativos; la transformación es la misma que ejecuta scripts/build_catalog.py sobre los datos completos.
frutas_y_hortalizas — Agrícolas
| Columna del boletín | Valor |
|---|---|
Fecha |
2025-06-18 |
Producto |
Aguacate |
Calidad |
Primera |
Presentación |
Caja de 10 kg |
Origen |
Michoacán |
Precio Mín |
450.00 |
Precio Max |
520.00 |
Precio Frec |
480.00 |
Obs. |
-- |
La misma observación en prices (una fila por componente de precio):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Aguacate | Primera | Caja de 10 kg | caja |
min |
450.00 | 45.00 |
| Aguacate | Primera | Caja de 10 kg | caja |
max |
520.00 | 52.00 |
| Aguacate | Primera | Caja de 10 kg | caja |
mode |
480.00 | 48.00 |
destino_pescados_marinos — Pesqueros
| Columna del boletín | Valor |
|---|---|
Fecha |
2025-06-18 |
Producto |
Huachinango |
Presentación |
Kilogramo |
Origen |
Veracruz |
Pmín |
180.00 |
Pmáx |
220.00 |
Pfrec |
200.00 |
Obs. |
-- |
La misma observación en prices (una fila por componente de precio):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Huachinango | — | Kilogramo | kg |
min |
180.00 | 180.00 |
| Huachinango | — | Kilogramo | kg |
max |
220.00 | 220.00 |
| Huachinango | — | Kilogramo | kg |
mode |
200.00 | 200.00 |
aves_pollo_pza — Pecuarios
| Columna del boletín | Valor |
|---|---|
Fecha |
2025-06-18 |
Destino |
Todos |
Pechuga_Mín($/kg) |
78.00 |
Pechuga_Máx($/kg) |
92.00 |
Pechuga_Frec($/kg) |
85.00 |
PiernaMuslo_Mín($/kg) |
42.00 |
PiernaMuslo_Máx($/kg) |
55.00 |
PiernaMuslo_Frec($/kg) |
48.00 |
La misma observación en prices (una fila por componente de precio):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Pechuga de pollo | — | Kilogramo | kg |
min |
78.00 | 78.00 |
| Pechuga de pollo | — | Kilogramo | kg |
max |
92.00 | 92.00 |
| Pechuga de pollo | — | Kilogramo | kg |
mode |
85.00 | 85.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
min |
42.00 | 42.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
max |
55.00 | 55.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
mode |
48.00 | 48.00 |
azucar — Agrícolas
| Columna del boletín | Valor |
|---|---|
Fecha |
2025-06-18 |
Tipo Producto |
Azúcar estándar |
Ingenio |
Ing. Pánuco |
Mercado |
Todos |
destino |
Nacional |
Precio encuestado |
1150.00 |
La misma observación en prices (una fila por componente de precio):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Azúcar estándar | Ing. Pánuco | Bulto de 50 kg | bulto |
single |
1150.00 | 23.00 |
Mapeo de columnas
Tabla generada desde el registro core.canonical.ENDPOINTS. Cada columna de origen termina en un campo canónico o en una medida con su price_kind. El archivo standardization-note.json trae el mapeo columna por columna para las 47 tablas.
Agrícolas (5 endpoints)
| Endpoint | Producto | Presentación | Mercado | Origen | Variante / atributos | Medidas → price_kind |
|---|---|---|---|---|---|---|
aceites_comestibles |
Variedad |
Presentación |
destino |
— |
Producto |
Precio Mín → min · Precio Max → max · Precio Frec → mode |
azucar |
Tipo Producto |
Bulto de 50 kg | Mercado |
— |
Ingenio, destino_label ← destino |
Precio encuestado → single |
flores |
Producto |
Presentación |
destino |
Origen |
— | Precio Mín → min · Precio Max → max · Precio Frec → mode |
frutas_y_hortalizas |
Producto |
Presentación |
destino |
Origen |
Calidad |
Precio Mín → min · Precio Max → max · Precio Frec → mode |
granos_basicos |
Producto |
Kilogramo | destino |
Origen |
— | Precio Mín → min · Precio Max → max · Precio Frec → mode |
Pecuarios (22 endpoints)
| Endpoint | Producto | Presentación | Mercado | Origen | Variante / atributos | Medidas → price_kind |
|---|---|---|---|---|---|---|
aves_huevo |
Producto |
Presentación |
Destino |
— |
destino_1_label ← destino_1 |
Preciomínimo($/kg) → min · Preciomáximo($/kg) → max · Preciofrecuente($/kg) → mode |
aves_pollo_entero |
Producto |
Kilogramo | Destino |
Origen |
Marca, peso_en_pie ← Pesoen pie(kg), peso_en_canal ← Pesoen canal(kg), destino_1_label ← destino_1 |
Precio($/kg) → single |
aves_pollo_pza |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
12 columnas: Pechuga de pollo, Pierna y muslo de pollo, Retazo de pollo, Vísceras de pollo × min/max/mode |
becerros_carne_canal |
— |
Kilogramo | Destino |
— |
peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columnas: Canal de becerro × min/max |
becerros_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columnas: Becerro en pie × min/max |
becerros_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columnas: Vísceras de becerro, Piel y sangre de becerro × min/max |
bovinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), num_canales ← Núm.canalesdesacrificio, destino_1_label ← destino_1 |
2 columnas: Canal de bovino × min/max |
bovinos_cortes |
Corte |
Kilogramo | Destino |
Origen |
destino_1_label ← destino_1 |
Preciomínimo($/kg) → min · Preciomáximo($/kg) → max |
bovinos_empacadoras |
— |
Kilogramo | Destino |
Origen |
num_canales ← No.decanales, destino_1_label ← destino_1 |
1 columna: Canal de bovino × single |
bovinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
4 columnas: Novillo en pie, Novillona en pie, Vaca en pie, Toro en pie × single |
bovinos_precios_origen |
— |
Kilogramo | Destino |
Origen |
destino_1_label ← destino_1 |
5 columnas: Novillo de corral, Novillo de campo, Vaca vaquilla, Vaca desecho, Toro × single |
bovinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columnas: Vísceras de bovino, Piel y sangre de bovino × min/max |
caprinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
1 columna: Canal caprino × single |
caprinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columnas: Caprino en pie × min/max |
caprinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columnas: Vísceras caprinas, Piel y sangre caprina × min/max |
ovinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
1 columna: Canal ovino × single |
ovinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columnas: Ovino en pie × min/max |
ovinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columnas: Vísceras ovinas, Piel y sangre ovina × min/max |
porcinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
4 columnas: Canal porcino, Capote porcino × min/max |
porcinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
Tipo, num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columnas: Porcino en pie × min/max |
porcinos_obradores |
Producto |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
Preciopor Kilogramo($/kg) → single |
porcinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columnas: Vísceras porcinas, Grasa porcina × min/max |
Pesqueros (20 endpoints)
| Endpoint | Producto | Presentación | Mercado | Origen | Variante / atributos | Medidas → price_kind |
|---|---|---|---|---|---|---|
destino_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_agua_dulce |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_crustaceos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_moluscos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_pescados_marinos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
enlatados |
Producto |
Presentación |
Todos | — |
Marca, Tipocomercial |
Precioúnico → single |
Reglas de unidad y precio
Presentación → unidad → precio por kilo
parse_presentation traduce la presentación publicada a una unidad (kg, caja, manojo, docena, litro, …). kg_per_unit solo se llena cuando el boletín declara un peso —Caja de 10 kg, Caja con 48 latas de 425 gr— o cuando la presentación es el kilogramo. Con peso, price_per_kg = price_mxn / kg_per_unit; sin peso, price_per_kg queda NULL y presentations.conversion_note explica por qué.
Mercado → estado, nombre y tipo
parse_market separa el prefijo de estado (Q.R., N.L., Michoacán) del nombre del mercado o planta y clasifica el tipo (mercado, rastro_planta, obrador, zona_metropolitana, nacional). La etiqueta original se conserva en market_aliases, de modo que cada fila puede rastrearse hasta el texto exacto del boletín. El catálogo resuelve 1.060 etiquetas en 227 mercados canónicos.
Calidad de dato
Las reglas se aplican en el build y cada valor afectado queda registrado en dq_issues; nada se corrige en silencio.
| Regla | Acción | Qué hace |
|---|---|---|
negative_price |
nulled |
Cualquier precio < 0 pasa a NULL; la fila de origen se conserva en observations. |
zero_as_missing |
nulled |
0.00 significa «no reportado» en el origen: el precio pasa a NULL. Si todos los precios de la fila son 0, la fila no aporta hechos de precio. |
inverted_min_max |
swapped |
Cuando mín > máx los dos valores se intercambian (inversión de captura del origen); cada intercambio queda registrado con el par original. |
frec_out_of_range |
flagged |
El precio frecuente cae fuera de [mín, máx]; el valor se conserva y se marca porque el origen a veces publica una moda fuera del rango. |
Vistas de compatibilidad
Los 47 nombres de tabla originales siguen disponibles como vistas reconstruidas desde observations + prices, con las mismas columnas y en el mismo orden que el boletín. scripts/verify_catalog.py --roundtrip compara fila por fila todas las columnas que no son de precio: si una vista se desvía del origen, el build falla.
El resultado en números
Conteos del registro de endpoints y del último catálogo construido (docs/catalog-stats.json, 2026-11-05):
| Métrica | Valor |
|---|---|
| Reportes de origen | 47 |
| Esquemas de origen distintos | 25 |
| Tablas del modelo canónico | 10 |
| Filas de hechos de precio | 45.036.608 |
| Observaciones (filas de boletín) | 17.030.096 |
| Precios convertibles a MXN/kg | 90,5 % |
| Productos canónicos | 609 |
| Presentaciones distintas | 203 |
| Mercados canónicos | 227 |
| Etiquetas de mercado resueltas | 1.060 |
Límites
El modelo no inventa pesos: 90,5 % de las filas de precio se pueden llevar a MXN/kg; el resto corresponde a presentaciones por pieza, manojo, docena o litro, donde el boletín no publica peso. Esas filas se conservan con price_per_kg NULL y una nota de conversión.
Esta nota describe el pipeline, no la calidad del dato de origen: los huecos de cobertura y las correcciones del SNIIM se documentan aparte, en _manifest.json y en el ledger de data_gaps.