100 Días de Código en Python — Día 80: El lavado de manos del Dr. Semmelweis — distribuciones y pruebas t
Nivel: Grado décimo
Área: Tecnología e Informática
Modalidad: Tutorial práctico
Fuente: Curso "100 Days of Code: The Complete Python Pro Bootcamp" (Angela Yu, App Brewery). Contenido del Día 80 (nivel avanzado).
27 min de lectura
16 secciones
Este tutorial adapta pedagógicamente el Día 80. Las secciones 📝 Contenido original del curso provienen de las lecciones 591 a 596; el cuaderno de inicio y los archivos annual_deaths_by_clinic.csv y monthly_deaths.csv están en los recursos del curso (Dr Semmelweis Analysis (start).zip). En varias lecciones parte del código aparece solo en imágenes; aquí se reconstruye y se verifica. Los retos 🟢🟡🟠🔴, las secciones ¿qué es un valor p? y comunicar con datos, el proyecto extra y la etapa 5 del Laboratorio de Datos Abiertos son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.
Nota sobre las verificaciones: el 27 de septiembre de 2026, con Python 3.14, pandas 3.0.6, NumPy 2.5.3, Matplotlib 3.11.2, seaborn 0.13.2, plotly 7.1.0 y SciPy 1.18.1, y con una copia pública de los dos CSV (publicada en GitHub por otro estudiante del mismo curso), se ejecutó todo el código. Los resultados coinciden con el curso: 7,08 % de mortalidad en la década; 9,92 % en la clínica 1 y 3,88 % en la clínica 2; 10,5 % antes y 2,11 % después del lavado de manos (5 veces menos); valor p de 0,0000002985. Hay cuatro observaciones importantes: los datos mensuales corresponden a la clínica 1, no al hospital completo; el lavado obligatorio empezó en junio de 1847 (una lección dice "junio de 1846"); la reducción de 8,4 es de puntos porcentuales, no de "8,4 %"; y la prueba t del curso supone varianzas iguales, cuando la dispersión antes del lavado es casi 5 veces mayor (con la versión de Welch la conclusión se mantiene, con un valor p aún menor).
🏫 Para el aula: el tema incluye muertes de madres después del parto. Conviene presentarlo con respeto y enfocarlo en lo que enseña: cómo los datos, bien analizados y bien comunicados, salvan vidas.
Hoy serás el Dr. Ignaz Semmelweis, médico húngaro nacido en 1818 que trabajó en el Hospital General de Viena. En esa época nadie conocía las bacterias, los gérmenes ni los virus: se creía que las enfermedades venían del "mal aire" o de espíritus malignos. Pero en el siglo XIX los médicos empezaron a estudiar la anatomía, hacer autopsias y argumentar con datos. Semmelweis sospechaba que algo andaba mal en los procedimientos del hospital: ¿por qué morían tantas mujeres de fiebre puerperal en las salas de maternidad?
Aprenderás a construir un argumento convincente con datos; a superponer histogramas; a estimar una distribución con una KDE (Kernel Density Estimate); a usar SciPy para probar la significancia estadística con valores p; a resaltar partes de una serie de tiempo en Matplotlib; a configurar una leyenda; y a usar np.where().
Preparación: descarga el ZIP, sube el cuaderno a Google Drive, ábrelo con Colab y agrega los dos CSV.
Prerequisitos: dos ejes y localizadores de fechas (Día 75), media móvil (Día 73), plotly (Día 76), np.where y broadcasting (Día 77), histogramas y KDE de seaborn (Día 79). En tu computador: pip install scipy.
03
1. Explorar los datos y graficar nacimientos y muertes#
Reto 1: ¿forma de df_yearly y df_monthly?, ¿columnas?, ¿años?, ¿NaN o duplicados?, ¿nacimientos y muertes promedio por mes?
Los datos cubren de 1841 a 1849. Ambas tablas registran nacimientos y muertes; la anual los separa por clínica. No hay NaN (.info() o .isna().values.any()) ni duplicados: los datos parecen limpios. Con .describe(): en promedio, unos 267 nacimientos y 22,47 muertes por mes.
Reto 2: ¿qué tan peligroso era dar a luz? Con los datos anuales, el porcentaje de mujeres que murieron. (En comparación, Estados Unidos registró 18,5 muertes maternas por 100 000 partos en 2013, un 0,018 %.)
Python
12
prob = df_yearly.deaths.sum()/ df_yearly.births.sum()*100print(f'Chances of dying in the 1840s in Vienna: {prob:.3}%')# 7.08%
"Si alguien me diera una bolsa de 100 dulces y me dijera que 7 me matarían, (probablemente) no me los comería".
Reto 3: gráfico con dos ejes y: localizadores de años y meses, eje x ajustado a los datos, cuadrícula, colores skyblue y crimson, línea discontinua para las muertes, grosor 3 y 2. ¿Notas algo a finales de los 1840?
enero de 1841 – marzo de 1849 (falta diciembre de 1841)
date, births, deaths
🛠️ Nota técnica (verificado): ¿de qué clínica son los datos mensuales? El curso habla del "total" de nacimientos y muertes, pero al sumar los datos mensuales por año, las muertes coinciden exactamente con las de la clínica 1 de 1843 a 1846 (274, 260, 241 y 459) y los nacimientos son del orden de esa clínica, no de las dos juntas. Los datos mensuales corresponden a la clínica 1, la de los médicos. Es justo la clínica donde se aplicó el lavado de manos, así que el análisis del curso tiene sentido, pero hay que decirlo: las tasas mensuales no son del hospital completo.
¿Tasa o conteo? El 7,08 % combina las dos clínicas de 1841 a 1846. Para comparar grupos de distinto tamaño siempre se usan tasas (muertes por cada 100 nacimientos), no conteos: una clínica con más partos tendrá más muertes aunque sea igual de segura.
El hospital tiene dos maternidades: la clínica 1, atendida por médicos y estudiantes de medicina (todos hombres), y la clínica 2, atendida por parteras.
Reto 1: líneas de plotly de nacimientos y muertes por clínica. ¿Cuál es más grande? ¿Aumentaron los pacientes? ¿Máximo de muertes en cada clínica?
Python
123
line = px.line(df_yearly, x='year', y='births', color='clinic',
title='Total Yearly Births by Clinic')
line.show()
Cada vez más mujeres daban a luz en el hospital; la clínica 1 era la más ocupada, y también tenía más muertes.
Reto 2: para comparar "manzanas con manzanas", la proporción de muertes por clínica:
Python
123456789
df_yearly['pct_deaths']= df_yearly.deaths / df_yearly.births
clinic_1 = df_yearly[df_yearly.clinic =='clinic 1']
avg_c1 = clinic_1.deaths.sum()/ clinic_1.births.sum()*100print(f'Average death rate in clinic 1 is {avg_c1:.3}%.')# 9.92%
clinic_2 = df_yearly[df_yearly.clinic =='clinic 2']
avg_c2 = clinic_2.deaths.sum()/ clinic_2.births.sum()*100print(f'Average death rate in clinic 2 is {avg_c2:.3}%.')# 3.88%
1842 fue un año terrible: murió cerca del 16 % en la clínica 1 y cerca del 7,6 % en la clínica 2. Aun así, la clínica 2 tuvo siempre una mortalidad menor. Eso desconcertaba a Semmelweis.
La historia continúa: primero pensó que era la posición del parto (de lado en la clínica de parteras, de espaldas en la de médicos): cambiarla no tuvo efecto. Luego, que el sacerdote que atravesaba la clínica 1 tocando una campana tras cada muerte asustaba tanto a las mujeres que enfermaban: cambió su ruta y no tuvo efecto. Frustrado, se fue de vacaciones a Venecia. Al volver supo que un colega patólogo había muerto tras cortarse el dedo durante la autopsia de una mujer fallecida por fiebre puerperal, con los mismos síntomas. Su nueva teoría: los médicos y estudiantes llevaban en las manos "partículas de cadáver" de las autopsias a la sala de partos.
📖 Resultados verificados y pensamiento científico#
Clínica
Nacimientos (1841–1846)
Muertes
Tasa
Peor año
Clínica 1 (médicos)
20 042
1 989
9,92 %
1842: 15,8 % (518 muertes)
Clínica 2 (parteras)
17 791
691
3,88 %
1842: 7,6 % (202 muertes)
Semmelweis siguió el método científico: observó una diferencia → propuso hipótesis (posición, sacerdote) → las probó cambiando una sola cosa → las descartó cuando los datos no cambiaron → formuló una nueva hipótesis a partir de una observación clave (la muerte del patólogo). Descartar hipótesis también es avanzar.
🛠️ Nota técnica: el reto pregunta por la "tasa mensual más alta" de cada clínica, pero la tabla por clínica es anual; con estos datos solo se puede responder con años.
🟢 Reto 1: grafica pct_deaths por clínica como porcentaje (multiplica por 100) y con etiquetas en español.
🟡 Reto 2: calcula cuántas veces más riesgosa era la clínica 1 que la clínica 2 en cada año.
🔴 Reto 3 — Pensamiento computacional: ¿qué otras explicaciones (distintas de las manos) podrían explicar la diferencia entre clínicas? Propón dos y diseña cómo las pondrías a prueba con datos.
Semmelweis ordenó que todo el personal se lavara las manos y los instrumentos no solo con agua y jabón, sino con una solución de cloro (un excelente desinfectante, aunque él no lo sabía: lo eligió para quitar el olor de las autopsias).
Reto 1: columna pct_deaths en df_monthly; dos subconjuntos, antes y después; tasas de cada periodo.
Python
12345678910
handwashing_start = pd.to_datetime('1847-06-01')# definida en el cuaderno
df_monthly['pct_deaths']= df_monthly.deaths / df_monthly.births
before_washing = df_monthly[df_monthly.date < handwashing_start]
after_washing = df_monthly[df_monthly.date >= handwashing_start]
bw_rate = before_washing.deaths.sum()/ before_washing.births.sum()*100
aw_rate = after_washing.deaths.sum()/ after_washing.births.sum()*100print(f'Average death rate before 1847 was {bw_rate:.4}%')# 10.53%print(f'Average death rate AFTER 1847 was {aw_rate:.3}%')# 2.15%
Reto 2: media móvil de 6 meses antes del lavado. Pista: pon las fechas como índice para que la columna de fechas no se pierda.
Reto 3: un gráfico con 3 líneas (tasa antes, tasa después y media móvil), con leyenda: antes como línea negra delgada discontinua; la media móvil más gruesa y carmesí; después, skyblue con marcadores redondos.
🛠️ Nota técnica: la lección dice que la orden se dio "en junio de 1846", pero el cuaderno usa 1847-06-01 y la lección siguiente habla de "antes de junio de 1847". Los datos confirman 1847: la tasa pasa de 12,2 % en mayo de 1847 a 2,2 % en junio. Históricamente, Semmelweis introdujo el lavado con cloro a mediados de 1847. Además, los mensajes before 1847 y AFTER 1847 deberían decir "antes/después de junio de 1847".
¿Por qué set_index('date')? Sin él, rolling().mean() intenta promediar también la columna de fechas. Verificado en pandas 3: DataError: Cannot aggregate non-numeric type: datetime64[us]. Con la fecha en el índice, solo se promedian las columnas numéricas y las fechas quedan disponibles para el eje x.
¿Por qué la coma en ma_line, =? Verificado: plt.plot(...) devuelve una lista de líneas (una por cada serie dibujada). Con la coma se "desempaca" la única línea. Alternativa más simple: poner label= en cada plot y llamar plt.legend() sin handles.
Una observación de los datos(adaptación): de enero a marzo de 1847, antes del lavado obligatorio, la tasa ya era baja (3,2 %, 1,9 %, 3,6 %), y en abril subió a 18,3 %. Los datos reales son "ruidosos": por eso no basta con mirar un par de meses, y hacen falta las herramientas de la sección 4.
Comparar fechas con texto:df_monthly.date < '1847-06-01' funciona en pandas, pero si date no es fecha, compara texto; convierte siempre con pd.to_datetime.
Olvidar label=: la leyenda aparece vacía.
Usar <= en ambos subconjuntos: junio de 1847 quedaría en los dos grupos.
Reto 1: tasa mensual promedio antes y después; diferencia; cuántas veces menor.
Python
1234567891011
avg_prob_before = before_washing.pct_deaths.mean()*100print(f'Chance of death during childbirth before handwashing: {avg_prob_before:.3}%.')# 10.5%
avg_prob_after = after_washing.pct_deaths.mean()*100print(f'Chance of death during childbirth AFTER handwashing: {avg_prob_after:.3}%.')# 2.11%
mean_diff = avg_prob_before - avg_prob_after
print(f'Handwashing reduced the monthly proportion of deaths by {mean_diff:.3}%!')# 8.4%
times = avg_prob_before / avg_prob_after
print(f'This is a {times:.2}x improvement!')# 5.0x
Reto 2: con np.where() (se abre en otra pestaña), una columna que indique antes o después, y un diagrama de caja de plotly. ¿Cómo cambiaron la media, el máximo, el mínimo y los cuartiles?
Python
123456
df_monthly['washing_hands']= np.where(df_monthly.date < handwashing_start,'No','Yes')
box = px.box(df_monthly, x='washing_hands', y='pct_deaths', color='washing_hands',
title='How Have the Stats Changed with Handwashing?')
box.update_layout(xaxis_title='Washing Hands?', yaxis_title='Percentage of Monthly Deaths')
box.show()
Se ve el pico de finales de 1842: más del 30 % de las mujeres que dieron a luz ese mes murieron. No solo bajó el promedio: también el rango (el máximo y el tercer cuartil).
Reto 3: histogramas superpuestos con plotly: color para dos grupos, histnorm='percent' (los periodos tienen distinta longitud), transparencia, distintos nbins y la caja arriba con marginal.
plt.figure(dpi=200)
sns.kdeplot(before_washing.pct_deaths, fill=True, clip=(0,1))
sns.kdeplot(after_washing.pct_deaths, fill=True, clip=(0,1))
plt.title('Est. Distribution of Monthly Death Rate Before and After Handwashing')
plt.xlim(0,0.40)
plt.show()
Sin clip, la estimación incluye tasas de muerte negativas: "los médicos se sorprenderían si un cadáver volviera a la vida después de una autopsia".
Reto 5: una prueba t para saber si la diferencia de medias es estadísticamente significativa. "Si el valor p es menor que 1 %, podemos estar 99 % seguros de que el lavado de manos marcó una diferencia".
Python
123456
import scipy.stats as stats
t_stat, p_value = stats.ttest_ind(a=before_washing.pct_deaths,
b=after_washing.pct_deaths)print(f'p-palue is {p_value:.10f}')# 0.0000002985print(f't-statstic is {t_stat:.4}')# 5.512
El valor p es 0,0000002985, muy por debajo del 1 %: la diferencia es altamente significativa y "podemos publicar nuestro artículo".
Dos formas de promediar(adaptación): el 10,53 % de la sección 3 divide el total de muertes entre el total de nacimientos (cada parto pesa igual); el 10,5 % de aquí promedia las tasas mensuales (cada mes pesa igual). Son parecidos porque los meses tienen un número similar de partos; con grupos muy desiguales podrían diferir bastante.
🛠️ Nota técnica: puntos porcentuales. Pasar de 10,5 % a 2,1 % es una reducción de 8,4 puntos porcentuales, no "de 8,4 %". En términos relativos, la tasa bajó un 80 % (es 5 veces menor). Confundir "puntos" y "por ciento" es de los errores más comunes en noticias y presentaciones.
🛠️ Nota técnica: con histnorm='percent', el eje y muestra porcentajes, no conteos: la etiqueta 'Count' del curso es incorrecta; debería decir "Porcentaje de meses". Y en la caja, la línea central es la mediana (el reto habla de "la media").
🛠️ Nota técnica (verificado): ¿varianzas iguales?ttest_ind supone por defecto que los dos grupos tienen la misma dispersión (equal_var=True). Aquí la desviación antes (0,071) es casi 5 veces la de después (0,015). La versión recomendada en ese caso es la prueba t de Welch:
Python
12
stats.ttest_ind(before_washing.pct_deaths, after_washing.pct_deaths, equal_var=False)# estadístico t ≈ 9.61, p ≈ 1.4e-15
La conclusión no cambia (el valor p es aún menor), pero la elección de la prueba debe justificarse.
🟢 Reto 1: cambia nbins a 10, 20 y 50. ¿Cuál comunica mejor? ¿Por qué?
🟡 Reto 2: repite la prueba con equal_var=False y con la prueba no paramétrica de Mann-Whitney (stats.mannwhitneyu). (Verificado: p ≈ 1,3 × 10⁻⁸.) ¿Cambia la conclusión?
🟠 Reto 3: calcula un intervalo de confianza del 95 % para la diferencia de medias (pista: el resultado de ttest_ind tiene el método .confidence_interval()).
La pregunta: si el lavado de manos no hubiera servido para nada, ¿qué tan raro sería ver, solo por azar, una diferencia tan grande como la observada (8,4 puntos)?
Un experimento con código (prueba de permutación): si las etiquetas "antes" y "después" no importaran, podríamos barajarlas al azar y la diferencia seguiría siendo parecida. Hagámoslo 10 000 veces:
En ninguna de las 10 000 barajadas el azar produjo una diferencia tan grande. Eso es lo que expresa un valor p muy pequeño.
Cómo leerlo bien:
✅ Correcto
❌ Incorrecto
"Si el lavado no tuviera efecto, una diferencia así sería extremadamente improbable"
"Hay un 99,99997 % de probabilidad de que el lavado funcione"
"La diferencia es estadísticamente significativa al 1 %"
"El efecto es grande" (eso lo dice la diferencia, no el valor p)
"Los datos son coherentes con que el lavado redujo las muertes"
"Queda demostrado que el lavado causó toda la reducción"
Límites del análisis: es un estudio de "antes y después", no un experimento con grupo de control. Otras cosas pudieron cambiar al mismo tiempo (las estaciones, el personal, la cantidad de autopsias). Lo que hace convincente el caso de Semmelweis es que todo encaja: la diferencia entre clínicas, el mecanismo (las autopsias), el momento del cambio y la magnitud del efecto.
08
6. 📖 El final de la historia: comunicar con datos#
Resumen del día: histogramas para ver distribuciones; superponer histogramas de series de distinta longitud; suavizar con una KDE y mejorarla con límites; valores p con SciPy; resaltar partes de una serie de tiempo; leyendas; np.where().
La trágica historia: a pesar de la evidencia, la comunidad médica rechazó la teoría de Semmelweis. En parte porque no fue diplomático: hacía ver que los médicos causaban la fiebre (lo que era cierto), y nadie quería oírlo. Pero también porque publicó sus datos en largas tablas sin visualizaciones, que hacían muy difícil ver lo que pasaba, y porque los argumentos estadísticos eran poco comunes en medicina. Su campaña beligerante le ganó enemigos poderosos; perdió su puesto y los médicos dejaron de lavarse las manos con cloro. Fue internado en un asilo a los 47 años, donde probablemente fue golpeado, y murió de sepsis, una infección de la sangre parecida a la que él luchó por prevenir. Solo unos 20 años después, con la teoría de los gérmenes de Louis Pasteur, su trabajo fue aceptado.
🛠️ Nota técnica: el texto dice que fue internado "en 1965"; es 1865 (nació en 1818 y murió a los 47 años).
Semmelweis tenía razón y tenía los datos, pero no logró convencer. Lecciones para cualquier proyecto de datos:
Un buen gráfico vale más que una tabla larga. El gráfico de la sección 3 muestra en un segundo lo que sus tablas ocultaban.
La forma de comunicar importa. Acusar genera resistencia; mostrar evidencia y proponer soluciones genera aliados.
Hace falta un mecanismo. Sin la teoría de los gérmenes, "partículas de cadáver" sonaba a especulación. Un resultado estadístico convence más cuando se explica por qué ocurre.
La evidencia no siempre gana sola. Por eso los científicos también necesitan saber escribir, graficar y dialogar.
09
7. 🚀 Proyecto del día: el informe que Semmelweis necesitaba#
df_monthly = pd.read_csv('monthly_deaths.csv')
handwashing_start ='1846-06-01'
before = df_monthly[df_monthly.date <= handwashing_start]
after = df_monthly[df_monthly.date >= handwashing_start]
roll = before.rolling(window=6).mean()print(f'Reducción: {(before.pct_deaths.mean()- after.pct_deaths.mean())*100:.3}%')
Encuentra seis problemas: la fecha no se convirtió (parse_dates), así que se comparan textos; la fecha del cambio es de 1846 (es junio de 1847); <= y >= ponen el mes del cambio en ambos grupos; rolling sin set_index('date') falla con la columna de fechas (DataError); pct_deaths no se ha creado; y la diferencia debería expresarse en puntos porcentuales.
Reto 1: recrea el gráfico de Semmelweis como lo haría un periódico: título que diga la conclusión ("Las muertes cayeron 5 veces tras el lavado de manos"), anotaciones y fuente.
Reto 2: investiga qué es la "sepsis materna" hoy y cuál es la tasa de mortalidad materna en Colombia (fuente: DANE o Instituto Nacional de Salud). Compárala con la de Viena en 1840.
10
8. 🌟 Proyecto extra: ¿funcionó la campaña del colegio?#
(Proyecto creado para esta adaptación; no existe en el curso original.)
Registren (con el comité) los kilos de material reciclado por semana durante al menos 8 semanas antes y 8 después de la campaña. Si el colegio ya tiene registros, úsenlos.
Grafiquen la serie con el "antes" y el "después" resaltados, una línea vertical en el inicio de la campaña y leyenda.
Comparen las distribuciones con cajas e histogramas normalizados.
Apliquen la prueba t de Welch y la de permutación de la sección 5.
Escriban una conclusión para la comunidad: cuánto cambió (en kilos y en porcentaje), si la diferencia es significativa y qué otras explicaciones podría haber (vacaciones, eventos, clima).
Ciclo de trabajo: formula el problema → inténtalo tú → consulta a la IA → lee y entiende → prueba → compara con la documentación oficial → modifica → explica con tus palabras.
Actividad 1 — Interpretar un valor p. Pregunta a una IA qué significa un valor p de 0,0000003 en este análisis. Si responde "99,99997 % de probabilidad de que el lavado funcione", explícale el error con la sección 5 y compara con una fuente confiable de estadística.
Actividad 2 — Elegir la prueba. Pide a una IA la prueba t para estos datos. ¿Menciona el supuesto de varianzas iguales? Consulta en la documentación de scipy.stats.ttest_ind qué hace equal_var=False.
Actividad 3 — Verificar la historia. Pregunta a una IA en qué año Semmelweis introdujo el lavado con cloro y en qué año murió. Contrasta con dos fuentes confiables y con los datos (el cambio de junio de 1847).
14
12. 🛠️ Correcciones técnicas al material original#
Archivo
Afirmación o comportamiento del curso
Observación
Tratamiento
592
Datos del "total" de nacimientos y muertes, de 1841 a 1849
Los mensuales son de la clínica 1 (enero de 1841 – marzo de 1849, sin diciembre de 1841); los anuales, de 1841 a 1846 (verificado)
Nota técnica de la sección 1
594
Lavado obligatorio desde "junio de 1846"
El cuaderno y los datos indican junio de 1847 (verificado)
Nota técnica de la sección 3
594
Mensajes "before 1847" / "AFTER 1847"
Antes/después de junio de 1847
Nota técnica de la sección 3
595
"Reducción del 8,4 %"
Son 8,4 puntos porcentuales; en relativo, 80 %
Nota técnica de la sección 4
595
"Si p < 1 %, estamos 99 % seguros de que el lavado marcó la diferencia"
El cuaderno del análisis con los gráficos y la tabla de pruebas superada.
El informe de una página para la junta del hospital.
El análisis de la campaña de reciclaje (proyecto extra).
La etapa 5 del Laboratorio de Datos Abiertos.
Una reflexión escrita: ¿qué habrías hecho distinto a Semmelweis para convencer a sus colegas?
Cuaderno de errores: fechas sin convertir, meses en ambos grupos, rolling con columna de fechas, ma_line sin coma, KDE sin clip, histogramas sin normalizar, puntos contra porcentaje, interpretación del valor p, prueba t con varianzas distintas.