Visualización del proyecto CanastaUY de precios al consumidor en Uruguay

Servicio Web para Obtener Precios al Consumidor

De datos abiertos a un backend consultable: limpieza de un histórico de precios al consumidor en Uruguay (2016–2025), modelado en PostgreSQL y lecciones reales del proceso.

El siguiente post está dedicado a un proyecto de investigación y desarrollo a partir del dataset abierto del histórico de precios al consumidor en Uruguay, con una ventana cercana a los 10 años (2016 a 2025).

Está destinado tanto para personas con curiosidad por los datos, como para gente de TI (data science, backend, etc.) que disfrute leer “cómo se construyó” algo, con tropiezos reales y decisiones imperfectas.

TL;DR: Armé un histórico de precios al consumidor de productos de la canasta básica en Uruguay con datos diarios desde 2016-01-01 hasta 2025-12-31 (10 años de registros). El dataset original tenía aproximadamente 200 millones de registros; después de varias iteraciones de limpieza terminé con una versión estable de 774,716 registros además de remover valores anómalos del dataset. Con esa base cargué los datos en una base relacional (normalizando categorías y productos) y construí un backend para consultarlos y analizarlos.

Contexto

Empecé esta investigación por una razón bastante simple: quería poner a prueba mis conocimientos de backend con un dataset grande. Quería fricción real: datos inconsistentes, volumen, decisiones de modelo, tiempos de import.

Buscando datasets que tuvieran algo de valor práctico, terminé en el Catálogo de Datos Abiertos. El que más me cerró por utilidad y por complejidad fue el histórico del Sistema de Información de Precios al Consumidor del MEF.

En mi caso, el punto de partida eran varios archivos con un tamaño considerable (por año: ~20 millones de filas, alrededor de 1.5GB).

Entre las cosas que me encontré temprano:

  • Algunos valores venían mal formateados y se leían como strings (especialmente precios).
  • Había diferencias de formatos entre archivos (delimitadores ; vs ,, encodings, etc.).
  • El dataset tenía metadata y documentación, pero aun así había que validar todo.

Selección de datos relevantes

Buscaba un histórico de precios de productos, no un sistema completo de establecimientos.

  • El dataset de establecimientos lo dejé afuera: no era necesario para mi objetivo.
  • El dataset de precios lo reduje y lo llevé hacia una tabla “amigable” para análisis y backend.

En el proceso también aparecieron inconsistencias entre catálogos y precios (IDs, separadores, etc.). Y aunque el catálogo de productos se mantuvo bastante estable año a año, en 2025 aparecieron productos nuevos; además, había productos en el catálogo que nunca tuvieron historial de precios real.

Del dataset a un formato trabajable

El punto de inflexión para mí fue aceptar que, si quería construir algo consultable y mantenible, necesitaba un formato que “aguante” el uso. No me servía trabajar con datos crudos sin una estructura clara; necesitaba algo que pudiera:

  • graficar sin que se rompa todo por una sola tienda,
  • consultar rápido por rangos de fechas,
  • importar a una base relacional sin drama.

Así que llevé la información a una versión agregada por producto y día, donde cada día resume el comportamiento del precio (mínimos, máximos y valores “típicos”). Esa versión consolidada (la que en la documentación llamo V1) terminó con 817,842 registros.

Limpieza y valores anómalos

En algún momento quise hacer algo inocente: ver cómo evolucionaba el precio del arroz con el paso de los años.

Y el gráfico me devolvió una cachetada: mínimos y máximos en ciertos períodos estaban totalmente fuera de escala. No era “una variación real”, era un dato que claramente no pertenecía a la misma distribución que el resto.

Evolución del precio del arroz (V4)

El problema de limpiar precios en una década

Cuando hablamos de precios en un período largo, hay un detalle obvio pero fácil de subestimar: la inflación existe. No podés meter en la misma bolsa un precio “normal” de 2016 con uno “normal” de 2025 y pretender que un umbral fijo funcione.

Entonces, en vez de mirar todo “de una”, pasé a comparar productos con sus pares y dentro de ventanas de tiempo acotadas. Eso me permitió detectar “lo raro” sin castigar el crecimiento natural de los precios con el paso del tiempo.

Iteraciones (V1 → V4)

Este proyecto tuvo varias versiones del dataset, pero la historia corta es:

  • V1: consolidado sin limpieza (sirve para entender el problema).
  • V2/V3: un primer enfoque conservador, útil para sacar lo “obviamente roto”, pero que se queda corto en casos más sutiles.
  • V4: un enfoque más robusto, donde el criterio se adapta mejor a la realidad de los datos.

El caso que me terminó de convencer fue justamente el que me detonaba el gráfico:

  • Hay registros donde el promedio está razonable,
  • pero el máximo de una tienda se va a un número absurdo,
  • y si vos solo mirás el promedio, el problema pasa desapercibido.

Comparación min/max y por qué un outlier “se esconde”

Con V4, el dataset quedó en 774,716 registros (se removieron 43,126, es decir 5.27%).

Un recorte adicional: productos sin historial

En paralelo, tuve otro hallazgo más aburrido pero importante: había productos en el catálogo que nunca tuvieron registros en el histórico de precios.

Para que la base de datos quedara consistente (y para no llevar “basura” a producción), removí esos productos del catálogo final:

  • catálogo original: 379 productos
  • catálogo “activo”: 306 productos
  • removidos: 73 productos sin historial

Persistencia y consistencia

Antes de elegir la base de datos, me pasó por la cabeza algo tentador: “esto es histórico, casi inmutable, ¿y si lo dejo como archivos y listo?”. Un JSON (o CSVs) en un storage tipo S3 y un servicio que lea y agregue podría haber funcionado.

Pero este proyecto era, sobre todo, una excusa para aprender. Y para aprender backend en serio, yo quería:

  • un esquema relacional con constraints,
  • migraciones,
  • índices,
  • consultas con joins,
  • y el tipo de problemas que aparecen cuando tu fuente de verdad ya no es un archivo.

Así que elegí PostgreSQL.

Normalización de categorías

Algo que cambió respecto a la “idea inicial” fue cómo modelar categorías:

En vez de guardar el nombre de la categoría “como texto” y confiar en que todo esté siempre bien escrito e idéntico, preferí normalizarla. Es más trabajo al principio, pero a cambio te da consistencia y te evita errores silenciosos.

Esto “complica” un poco la importación, pero te compra integridad referencial y un modelo más sano.

El diagrama general de la base está en:

Diagrama de base de datos

Validaciones: cuando el CSV “miente”

Una parte que me gustó de pasar por Postgres es que te obliga a responder preguntas incómodas:

  • ¿Hay duplicados por (producto, día)?
  • ¿Se respeta siempre que el mínimo, el valor “típico” y el máximo tengan sentido entre sí?
  • ¿Hay valores nulos en campos “obligatorios”?

En ese proceso encontré casos raros (por ejemplo, días donde un valor resumido no quedaba dentro del rango que debería). En vez de ignorarlo, preferí corregirlo antes de cargar, para que la base quedara coherente y las consultas no devolvieran cosas imposibles.

El servicio web como “producto”

Con los datos limpios y en Postgres, el siguiente salto fue el backend: dejar de pensar en “un dataset que yo miro” y pasar a “una fuente de datos que cualquiera pueda consultar”.

El backend está hecho con un stack clásico (API + base de datos + cache), con un objetivo claro: que consultar y analizar no sea “abrir un CSV”, sino una interacción simple y repetible.

Lo más importante, igual, no es la lista de rutas: es el cambio de perspectiva. Cuando pasás de análisis personal a servicio, empiezan a aparecer preguntas que antes no importaban tanto: cómo limitar consultas para que sean razonables, cómo hacer que la respuesta sea útil para alguien que no conoce tu proyecto, y cómo evitar que una mala request convierta la experiencia en una espera eterna.

Acceso y control de uso

Aunque este dataset sea público, exponer un servicio sin ningún control termina siendo una invitación al abuso (o a un mal uso involuntario).

Para consumo programático (scripts, notebooks, integraciones), el mecanismo más cómodo es:

  • API Keys

Porque evita fricción: copiás una key, la usás, listo.

Y para el caso “dashboard / manejo de cuenta”, está el camino de JWT, pensando en una eventual interfaz web.

La idea fue simple: dos formas de autenticación para dos tipos de consumo distintos.

Cierre: qué me llevé de este proyecto

Algunas cosas que me quedaron (más allá del código):

  • Un dataset grande no te enseña solo “performance”: te obliga a ser humilde con la calidad de datos.
  • Los outliers no son un detalle: pueden cambiar por completo lo que “ves” en un gráfico o en una métrica.
  • La inflación (y el tiempo) obligan a pensar en ventanas: lo “normal” depende del contexto.
  • Modelar en Postgres y poner constraints te hace descubrir problemas que un CSV perdona.
  • El backend deja de ser “una API” cuando hay que pensar en límites, cache, UX de consumo, y errores legibles.

Si te interesa el lado más técnico (sin que sea un paper), dejé documentación en el repo: versiones del dataset, metodología de limpieza, esquema de base y fases del backend.

Repositorio público en GitHub: https://github.com/GabrielDLeon/canasta-uy