<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: David Naranjo Ramírez</title>
    <description>The latest articles on DEV Community by David Naranjo Ramírez (@dnarram).</description>
    <link>https://dev.to/dnarram</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3954349%2Fce4da5bc-3e06-454f-90fc-31330643dc46.png</url>
      <title>DEV Community: David Naranjo Ramírez</title>
      <link>https://dev.to/dnarram</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dnarram"/>
    <language>en</language>
    <item>
      <title>Mi INSERT tardaba 25 minutos y no era culpa de los datos: construyendo un Data Warehouse de e-commerce con PostgreSQL</title>
      <dc:creator>David Naranjo Ramírez</dc:creator>
      <pubDate>Sun, 12 Jul 2026 21:59:17 +0000</pubDate>
      <link>https://dev.to/evolve-space/mi-insert-tardaba-25-minutos-y-no-era-culpa-de-los-datos-construyendo-un-data-warehouse-de-4e14</link>
      <guid>https://dev.to/evolve-space/mi-insert-tardaba-25-minutos-y-no-era-culpa-de-los-datos-construyendo-un-data-warehouse-de-4e14</guid>
      <description>&lt;p&gt;Cargar 112.647 filas en una tabla de hechos debería tardar segundos. A mí me tardaba más de 25 minutos, y acababa cancelando la query. Los datos estaban bien, el SQL estaba bien, las dimensiones se poblaban sin problema. El culpable era otro, y descubrirlo fue la parte más instructiva de todo el proyecto.&lt;/p&gt;

&lt;p&gt;Todo esto surgió construyendo un &lt;strong&gt;Data Warehouse en estrella&lt;/strong&gt; sobre datos reales de e-commerce: no una tabla bonita para hacer un &lt;code&gt;SELECT *&lt;/code&gt;, sino un modelo dimensional completo, reproducible desde cero, capaz de responder preguntas de negocio de verdad.&lt;/p&gt;




&lt;h2&gt;
  
  
  El dataset
&lt;/h2&gt;

&lt;p&gt;Trabajé con el &lt;strong&gt;Brazilian E-Commerce Public Dataset by Olist&lt;/strong&gt;: pedidos reales de un marketplace brasileño entre septiembre de 2016 y octubre de 2018. Son 9 CSV relacionados entre sí:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;99.441 pedidos y &lt;strong&gt;112.650 líneas de venta&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;103.886 pagos y 104.719 reseñas&lt;/li&gt;
&lt;li&gt;32.951 productos, 3.095 vendedores&lt;/li&gt;
&lt;li&gt;1.000.163 registros de geolocalización&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Y con trampas de datos reales que hay que ver antes de que te muerdan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Un pedido puede tener varios pagos y varias reseñas.&lt;/strong&gt; Si los unes tal cual a la tabla de hechos, &lt;strong&gt;duplicas ventas&lt;/strong&gt;. Es el error clásico y silencioso: los totales salen inflados y nadie se entera.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;customer_id&lt;/code&gt; no es un cliente.&lt;/strong&gt; Olist crea uno por cada pedido; la persona real es &lt;code&gt;customer_unique_id&lt;/code&gt;. Contar mal aquí te cambia el KPI: hay 99.441 cuentas frente a 96.096 personas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El CSV de productos trae una errata en la cabecera&lt;/strong&gt; (&lt;code&gt;product_name_lenght&lt;/code&gt;, con "lenght"). Si tu esquema la escribe bien y cargas por interfaz gráfica (que empareja por &lt;em&gt;nombre&lt;/em&gt;), esas columnas se quedan &lt;strong&gt;vacías&lt;/strong&gt; sin que nadie avise.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  El proceso
&lt;/h2&gt;

&lt;p&gt;Monté una arquitectura en capas: &lt;strong&gt;CSV → staging → modelo dimensional → vistas → análisis&lt;/strong&gt;, todo en cuatro scripts ejecutables en orden y &lt;strong&gt;idempotentes&lt;/strong&gt; (el esquema se recrea desde cero, se puede relanzar mil veces).&lt;/p&gt;

&lt;p&gt;El modelo es un &lt;strong&gt;star schema&lt;/strong&gt;: una tabla de hechos &lt;code&gt;fact_sales&lt;/code&gt; al grano de &lt;em&gt;línea de producto dentro de un pedido&lt;/em&gt;, y cinco dimensiones (cliente, producto, vendedor, pago y fecha), con claves sustitutas, PK/FK, &lt;code&gt;CHECK&lt;/code&gt; y &lt;code&gt;UNIQUE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;La clave del ETL está en &lt;strong&gt;deduplicar las relaciones 1:N antes de unirlas a la fact&lt;/strong&gt;. Con &lt;code&gt;ROW_NUMBER()&lt;/code&gt; me quedo con el pago principal (el de mayor importe) y la reseña más reciente de cada pedido. Así el grano se respeta y las ventas no se multiplican.&lt;/p&gt;




&lt;h2&gt;
  
  
  Y aquí llegó el INSERT eterno
&lt;/h2&gt;

&lt;p&gt;Las dimensiones cargaban perfectas. La fact se quedaba colgada. Descarté bloqueos (la sesión estaba &lt;code&gt;active&lt;/code&gt;, no esperando), descarté fechas corruptas (el rango era correcto). ¿Entonces?&lt;/p&gt;

&lt;p&gt;El problema estaba en que &lt;strong&gt;todo el ETL corre dentro de una única transacción&lt;/strong&gt;. Cuando cargo la fact, las dimensiones se acaban de rellenar en esa misma transacción sin confirmar: PostgreSQL &lt;strong&gt;no tiene estadísticas actualizadas&lt;/strong&gt; de ellas y &lt;strong&gt;cree que están vacías&lt;/strong&gt;. Con esa estimación errónea, el planificador elige un &lt;strong&gt;Nested Loop con Seq Scan&lt;/strong&gt; — recorrer la dimensión entera por cada una de las 112.650 filas. Miles de millones de comparaciones.&lt;/p&gt;

&lt;p&gt;La solución cabe en cinco líneas, justo antes de cargar la fact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;ecommerce_dw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_customer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;ecommerce_dw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_product&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;ecommerce_dw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_seller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;ecommerce_dw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_payment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="n"&gt;ecommerce_dw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim_date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ANALYZE&lt;/code&gt; actualiza las estadísticas (y, a diferencia de &lt;code&gt;VACUUM&lt;/code&gt;, sí funciona dentro de una transacción). El planificador pasa a &lt;strong&gt;Hash Join&lt;/strong&gt; y la carga baja a segundos.&lt;/p&gt;

&lt;p&gt;Lección: &lt;strong&gt;el optimizador no es magia, es un modelo estadístico&lt;/strong&gt;. Si le mientes sobre el tamaño de tus tablas, te devuelve un plan absurdo. Y no te avisa: simplemente tarda para siempre.&lt;/p&gt;




&lt;h2&gt;
  
  
  Los resultados
&lt;/h2&gt;

&lt;p&gt;Con el DW cargado (112.647 líneas, 98.665 pedidos, 95.419 clientes reales, 15,8M R$ de facturación), escribí 12 consultas de negocio con CTEs, funciones ventana y funciones propias. Tres hallazgos que me sorprendieron:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. La logística no influye en la satisfacción: la determina.&lt;/strong&gt; Cruzando retraso de entrega con valoración media (de pedidos entregados):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entrega&lt;/th&gt;
&lt;th&gt;Valoración media&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sin retraso&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,21&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retraso leve (1-3 días)&lt;/td&gt;
&lt;td&gt;3,23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retraso medio (4-7 días)&lt;/td&gt;
&lt;td&gt;2,09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retraso grave (&amp;gt;7 días)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,70&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No es una correlación suave. Es un desplome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nadie repite.&lt;/strong&gt; Solo el &lt;strong&gt;3,05%&lt;/strong&gt; de los clientes vuelve a comprar, y aportan el 5,71% de los ingresos. El marketplace es excelente captando y pésimo reteniendo. Ese es el mayor agujero de negocio del dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Los "power sellers" mandan.&lt;/strong&gt; El cuartil superior de vendedores (&lt;code&gt;NTILE(4)&lt;/code&gt;) genera el &lt;strong&gt;86,58%&lt;/strong&gt; de la facturación. Más concentrado incluso que un Pareto 80/20.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que aprendí
&lt;/h2&gt;

&lt;p&gt;Lo que más me marcó no fue el SQL avanzado, sino que &lt;strong&gt;las decisiones invisibles son las que arruinan un análisis&lt;/strong&gt;: un JOIN mal planteado que duplica ventas, contar clientes por la columna equivocada, una errata en una cabecera. Nada de eso lanza un error. Simplemente te da un número falso con toda la confianza del mundo.&lt;/p&gt;

&lt;p&gt;Lo siguiente que tiene sentido: cargas incrementales en lugar de recrear el DW entero, y una dimensión de cliente con historial (SCD tipo 2) para seguir la evolución del comportamiento de compra.&lt;/p&gt;




&lt;p&gt;📁 Repo completo en GitHub: &lt;a href="https://github.com/dnarram/olist-ecommerce-datawarehouse" rel="noopener noreferrer"&gt;https://github.com/dnarram/olist-ecommerce-datawarehouse&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Proyecto desarrollado durante el &lt;a href="//evolve.es"&gt;Máster en Data Science de Evolve&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>datawarehouse</category>
      <category>dataengineering</category>
      <category>postgres</category>
    </item>
    <item>
      <title>10.430 muertes, 10 preguntas y un pipeline en Python: lo que los datos de violencia policial en EE.UU. no te cuentan a simple vista</title>
      <dc:creator>David Naranjo Ramírez</dc:creator>
      <pubDate>Wed, 27 May 2026 16:40:01 +0000</pubDate>
      <link>https://dev.to/evolve-space/10430-muertes-10-preguntas-y-un-pipeline-en-python-lo-que-los-datos-de-violencia-policial-en-54j6</link>
      <guid>https://dev.to/evolve-space/10430-muertes-10-preguntas-y-un-pipeline-en-python-lo-que-los-datos-de-violencia-policial-en-54j6</guid>
      <description>&lt;p&gt;Cuando el Washington Post empezó a contar los muertos en 2015, el FBI subestimaba casi el 50% de los casos. Ese dato me enganchó. No por el morbo, sino por lo que implica metodológicamente: si los registros oficiales fallan de manera tan estrepitosa, ¿qué más se pierde cuando no miras bien los datos?&lt;/p&gt;

&lt;p&gt;Ese fue el punto de partida de &lt;strong&gt;EDAFatalForce&lt;/strong&gt;, mi proyecto de Análisis Exploratorio de Datos del Máster de Data Science en Evolve.&lt;/p&gt;




&lt;h2&gt;
  
  
  El dataset: periodismo de datos como fuente primaria
&lt;/h2&gt;

&lt;p&gt;El core del proyecto es la base de datos &lt;strong&gt;Fatal Force&lt;/strong&gt; del Washington Post — 10.430 incidentes fatales entre 2015 y 2024 con variables como raza, edad, armamento, comportamiento de fuga, nivel de amenaza percibida y si el agente llevaba cámara corporal.&lt;/p&gt;

&lt;p&gt;Para enriquecerlo crucé tres fuentes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Census Bureau ACS 2020&lt;/strong&gt; — datos socioeconómicos de 3.221 condados (ingresos, pobreza, desempleo)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NCSL&lt;/strong&gt; — políticas de cámara corporal por estado&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabla FIPS&lt;/strong&gt; — para enlazar condados con el Census&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;El mayor desafío no fue el modelado. Fue la calidad del dato. El &lt;strong&gt;45% de los registros no tenía condado&lt;/strong&gt;, lo que bloqueaba cualquier join con el Census. La solución: construir internamente un mapa &lt;code&gt;ciudad + estado → condado&lt;/code&gt; usando la moda de los registros que sí lo tenían. Resultado: de 45% de nulos a 17,4%. Un cambio que desbloqueó más de 2.800 filas para el análisis socioeconómico.&lt;/p&gt;




&lt;h2&gt;
  
  
  El proceso: bugs, fixes y un pipeline modular
&lt;/h2&gt;

&lt;p&gt;El proyecto está estructurado como un pipeline reproducible en &lt;code&gt;src/&lt;/code&gt;: limpieza → enriquecimiento → feature engineering → EDA. Un solo &lt;code&gt;python main.py&lt;/code&gt; lo ejecuta todo, incluyendo la descarga del Census.&lt;/p&gt;

&lt;p&gt;Durante la auditoría encontré dos bugs críticos en el feature engineering que hubieran invalidado análisis completos:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1 — La paradoja de la fuga estaba rota.&lt;/strong&gt; El valor raw &lt;code&gt;"not"&lt;/code&gt; (53% de los registros) no coincidía con la regex diseñada para mapear "no huía", así que más de 5.500 víctimas que no huían quedaban clasificadas como comportamiento desconocido. La pregunta Q2 entera era inválida.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 2 — "Undetermined" contaba como armado.&lt;/strong&gt; Los 463 registros con armamento &lt;code&gt;"undetermined"&lt;/code&gt; heredaban &lt;code&gt;is_armed = 1.0&lt;/code&gt; por caer en el &lt;code&gt;default&lt;/code&gt; del &lt;code&gt;np.select&lt;/code&gt;. Víctimas con armamento desconocido aparecían como armadas en todas las tasas.&lt;/p&gt;

&lt;p&gt;Ambos fixes son de una línea. El impacto analítico, considerable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resultados: lo que los datos dicen y lo que no
&lt;/h2&gt;

&lt;p&gt;Diez preguntas, diez respuestas con tests estadísticos. Las más reveladoras:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0l7gx4k7yionjv315u0o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0l7gx4k7yionjv315u0o.png" alt=" " width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El ranking de estados respecto a número de incidentes fatales cambia por completo al normalizar.&lt;/strong&gt; California lidera con +1200 incidentes brutos. Pero al dividir por población, Misisipi sube al primer puesto con 85,5 casos por 100.000 habitantes. El volumen engaña. La tasa informa.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fawc5t6scl78wgx7jpwou.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fawc5t6scl78wgx7jpwou.png" alt=" " width="799" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Los protocolos de crisis mental funcionan.&lt;/strong&gt; Los incidentes con signos de enfermedad mental presentan una tasa de amenaza letal del 35,3% frente al 44,4% del resto (χ²=55,18, p&amp;lt;0,001). El resultado más robusto estadísticamente de todo el análisis, y el más contraintuitivo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubvbcq3btinhpc8bl727.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubvbcq3btinhpc8bl727.png" alt=" " width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Huir en coche es más peligroso que no huir.&lt;/strong&gt; La tasa de amenaza letal resulta 4 puntos y medio porcentuales mayor en persecuciones vehiculares que en personas que no huyen. Un vehículo a alta velocidad es percibido como amenaza física directa, no solo como evasión.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuo6uoanth1yomuhrrjso.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuo6uoanth1yomuhrrjso.png" alt=" " width="800" height="307"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entre víctimas desarmadas, la raza sigue siendo predictora.&lt;/strong&gt; Las víctimas negras desarmadas tienen una tasa letal 4,2 puntos superior a las blancas (χ²=11,65, p=0,003). &lt;code&gt;threat_level&lt;/code&gt; es la narrativa del agente, no un hecho verificado — esa limitación es parte del análisis, no una nota al pie.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que aprendí
&lt;/h2&gt;

&lt;p&gt;Que los bugs de feature engineering son los más peligrosos porque no rompen el código — producen resultados plausibles pero incorrectos. Y que documentar las limitaciones no debilita un análisis: lo hace honesto y, paradójicamente, más creíble.&lt;/p&gt;

&lt;p&gt;Si lo repitiera, integraría coordenadas lat/lon para análisis espaciales con DBSCAN geográfico. Los hotspots por condado cuentan la mitad de la historia; los hotspots dentro del condado contarían la otra.&lt;/p&gt;




&lt;p&gt;📁 Repo completo en GitHub: https://github.com/dnarram/Proyecto-Master-DataScience-Evolve-DavidNaranjoRamirez-EDAFatalForce&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Proyecto desarrollado durante el &lt;a href="https://evolve.es" rel="noopener noreferrer"&gt;Máster en Data Science de Evolve&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>python</category>
      <category>panda</category>
      <category>jupyter</category>
    </item>
  </channel>
</rss>
