Curso: Herramientas Estadísticas y Computacionales para el Análisis de Datos en Investigación Bioquímica
Sesión 2: Estadística descriptiva y visualización de datos científicos
Docente: Andrés García Medina
Contacto: andgarm.n@gmail.com
Los datos son sintéticos y se diseñaron exclusivamente con fines docentes.
Al terminar esta demostración podrás:
groupby y agg;actividad_enzimatica_limpia_equipo.csv de classroom.Shift + Enter.El archivo proviene de la limpieza realizada en la sesión 1.
¿Cómo cambian el centro y la variabilidad de la actividad enzimática entre niveles de pH y temperatura?
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
datos = pd.read_csv("actividad_enzimatica_limpia_equipo.csv", encoding="utf-8-sig")
datos.head()
| muestra_id | fecha | lote_enzima | operador | ph | temperatura_c | replica_biologica | replica_tecnica | tiempo_reaccion_min | absorbancia_inicial_405 | absorbancia_final_405 | actividad_u_ml | observacion | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | AE-PH5-T25-B1-R1 | 2026-07-20 | L01 | Ana | 5.0 | 25 | 1 | 1 | 2.0 | 0.0496 | 0.1301 | 0.4025 | NaN |
| 1 | AE-PH5-T25-B1-R2 | 2026-07-20 | L01 | Ana | 5.0 | 25 | 1 | 2 | 2.0 | 0.0482 | 0.1331 | 0.4245 | NaN |
| 2 | AE-PH5-T25-B2-R1 | 2026-07-21 | L02 | Luis | 5.0 | 25 | 2 | 1 | 2.0 | 0.0398 | 0.1244 | 0.4230 | NaN |
| 3 | AE-PH5-T25-B2-R2 | 2026-07-21 | L02 | Luis | 5.0 | 25 | 2 | 2 | 2.0 | 0.0361 | 0.1205 | 0.4220 | NaN |
| 4 | AE-PH5-T25-B3-R1 | 2026-07-22 | L03 | Ana | 5.0 | 25 | 3 | 1 | 2.0 | 0.0427 | 0.1392 | 0.4825 | NaN |
Cada combinación de pH y temperatura contiene tres réplicas biológicas. Cada réplica biológica tiene dos lecturas técnicas. Para describir variabilidad biológica, primero promediamos las dos lecturas técnicas dentro de cada preparación independiente.
# Nivel 1: lecturas técnicas → unidades biológicas
claves_biologicas = ["ph", "temperatura_c", "replica_biologica"]
biologicas = (
datos
.groupby(claves_biologicas, as_index=False)
.agg(
actividad_u_ml=("actividad_u_ml", "mean"),
de_tecnica_u_ml=("actividad_u_ml", "std"),
n_tecnicas=("actividad_u_ml", "size"),
)
)
print("Lecturas instrumentales:", len(datos))
print("Unidades biológicas:", len(biologicas))
print("Réplicas técnicas por unidad:", biologicas["n_tecnicas"].unique())
biologicas.head()
Lecturas instrumentales: 90 Unidades biológicas: 45 Réplicas técnicas por unidad: [2]
| ph | temperatura_c | replica_biologica | actividad_u_ml | de_tecnica_u_ml | n_tecnicas | |
|---|---|---|---|---|---|---|
| 0 | 5.0 | 25 | 1 | 0.41350 | 0.015556 | 2 |
| 1 | 5.0 | 25 | 2 | 0.42250 | 0.000707 | 2 |
| 2 | 5.0 | 25 | 3 | 0.47900 | 0.004950 | 2 |
| 3 | 5.0 | 37 | 1 | 0.48425 | 0.015910 | 2 |
| 4 | 5.0 | 37 | 2 | 0.43675 | 0.019445 | 2 |
La tabla pasó de 90 lecturas a 45 unidades biológicas: 15 tratamientos por 3 preparaciones independientes. El tamaño de muestra por tratamiento es, por tanto, n = 3, no n = 6.
# Nivel 2: unidades biológicas → resumen por tratamiento
claves_tratamiento = ["ph", "temperatura_c"]
resumen = (
biologicas
.groupby(claves_tratamiento, as_index=False)
.agg(
n=("actividad_u_ml", "size"),
media=("actividad_u_ml", "mean"),
mediana=("actividad_u_ml", "median"),
varianza=("actividad_u_ml", "var"),
de=("actividad_u_ml", "std"),
ee=("actividad_u_ml", "sem"),
)
)
resumen["cv_pct"] = 100 * resumen["de"] / resumen["media"]
resumen.round({
"media": 4,
"mediana": 4,
"varianza": 6,
"de": 4,
"ee": 4,
"cv_pct": 2,
})
| ph | temperatura_c | n | media | mediana | varianza | de | ee | cv_pct | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 5.0 | 25 | 3 | 0.4383 | 0.4225 | 0.001261 | 0.0355 | 0.0205 | 8.10 |
| 1 | 5.0 | 37 | 3 | 0.4935 | 0.4842 | 0.003831 | 0.0619 | 0.0357 | 12.54 |
| 2 | 5.0 | 45 | 3 | 0.4245 | 0.4072 | 0.001086 | 0.0330 | 0.0190 | 7.76 |
| 3 | 6.0 | 25 | 3 | 0.8981 | 0.9265 | 0.005078 | 0.0713 | 0.0411 | 7.93 |
| 4 | 6.0 | 37 | 3 | 1.1787 | 1.1837 | 0.000973 | 0.0312 | 0.0180 | 2.65 |
| 5 | 6.0 | 45 | 3 | 0.9907 | 0.9895 | 0.000067 | 0.0082 | 0.0047 | 0.83 |
| 6 | 7.0 | 25 | 3 | 1.2840 | 1.2830 | 0.000086 | 0.0093 | 0.0054 | 0.72 |
| 7 | 7.0 | 37 | 3 | 1.5847 | 1.5827 | 0.000100 | 0.0100 | 0.0058 | 0.63 |
| 8 | 7.0 | 45 | 3 | 1.3283 | 1.3250 | 0.000051 | 0.0071 | 0.0041 | 0.54 |
| 9 | 8.0 | 25 | 3 | 0.8658 | 0.8688 | 0.000041 | 0.0064 | 0.0037 | 0.74 |
| 10 | 8.0 | 37 | 3 | 1.1436 | 1.1458 | 0.000629 | 0.0251 | 0.0145 | 2.19 |
| 11 | 8.0 | 45 | 3 | 1.0042 | 1.0065 | 0.004409 | 0.0664 | 0.0383 | 6.61 |
| 12 | 9.0 | 25 | 3 | 0.4253 | 0.3922 | 0.003435 | 0.0586 | 0.0338 | 13.78 |
| 13 | 9.0 | 37 | 3 | 0.5000 | 0.5145 | 0.000984 | 0.0314 | 0.0181 | 6.27 |
| 14 | 9.0 | 45 | 3 | 0.4184 | 0.4405 | 0.002954 | 0.0544 | 0.0314 | 12.99 |
Podemos hacer un query solo para temperatura_c = 37
resumen_37 = resumen.query("temperatura_c == 37").copy()
resumen_37.sort_values("ph").round({
"media": 4,
"mediana": 4,
"varianza": 6,
"de": 4,
"ee": 4,
"cv_pct": 2,
})
| ph | temperatura_c | n | media | mediana | varianza | de | ee | cv_pct | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 5.0 | 37 | 3 | 0.4935 | 0.4842 | 0.003831 | 0.0619 | 0.0357 | 12.54 |
| 4 | 6.0 | 37 | 3 | 1.1787 | 1.1837 | 0.000973 | 0.0312 | 0.0180 | 2.65 |
| 7 | 7.0 | 37 | 3 | 1.5847 | 1.5827 | 0.000100 | 0.0100 | 0.0058 | 0.63 |
| 10 | 8.0 | 37 | 3 | 1.1436 | 1.1458 | 0.000629 | 0.0251 | 0.0145 | 2.19 |
| 13 | 9.0 | 37 | 3 | 0.5000 | 0.5145 | 0.000984 | 0.0314 | 0.0181 | 6.27 |
fig, ax = plt.subplots(figsize=(8, 4.6))
PALETTE = sns.color_palette("viridis")
sns.histplot(
data=biologicas,
x="actividad_u_ml",
bins="auto",
color=PALETTE[0],
edgecolor="white",
ax=ax,
)
ax.set(
title="Distribución global de la actividad biológica media",
xlabel="Actividad enzimática (U/mL)",
ylabel="Frecuencia",
)
plt.tight_layout()
plt.show()
El histograma mezcla los 15 tratamientos. Sirve para observar la distribución global, pero no representa la forma de una condición específica. Con sólo tres réplicas biológicas por tratamiento, un histograma separado para cada grupo sería poco informativo.
fig, ax = plt.subplots(figsize=(9, 4.8))
sns.boxplot(
data=biologicas,
x="ph",
y="actividad_u_ml",
hue="temperatura_c",
palette="viridis",
ax=ax,
)
ax.set(
title="Actividad por pH y temperatura",
xlabel="pH",
ylabel="Actividad enzimática (U/mL)",
)
ax.legend(title="Temperatura (°C)", bbox_to_anchor=(1.02, 1), loc="upper left")
plt.tight_layout()
plt.show()
La caja resume mediana e intervalo intercuartílico.
# Seleccionar los datos obtenidos a 37 °C
foco = biologicas[
biologicas["temperatura_c"] == 37
]
# Crear la gráfica
plt.figure(figsize=(8, 5))
sns.scatterplot(
data=foco,
x="ph",
y="actividad_u_ml",
hue="replica_biologica",
palette="viridis",
s=90,
)
plt.title("Réplicas biológicas a 37 °C")
plt.xlabel("pH")
plt.ylabel("Actividad enzimática (U/mL)")
plt.legend(title="Réplica biológica")
plt.tight_layout()
plt.show()
# Ordenar los resultados por pH
orden = resumen_37.sort_values("ph")
plt.figure(figsize=(8, 5))
# Curva de medias con barras de desviación estándar
plt.errorbar(
orden["ph"],
orden["media"],
yerr=orden["de"],
marker="o",
linestyle="-",
label="Media ± 1 DE",
)
# Mostrar las réplicas biológicas
plt.scatter(
foco["ph"],
foco["actividad_u_ml"],
color="black",
s=35,
label="Réplicas biológicas",
)
plt.title("Actividad enzimática a 37 °C según el pH")
plt.xlabel("pH")
plt.ylabel("Actividad enzimática (U/mL)")
plt.xticks(orden["ph"])
plt.legend()
plt.tight_layout()
plt.show()
Estas barras representan media ± 1 desviación estándar. Si se usara EE, el mensaje sería precisión de la media y las barras serían menores. En ambos casos, el pie de figura debe indicar n = 3 réplicas biológicas por pH.
import matplotlib.pyplot as plt
# Seleccionar y ordenar los resultados obtenidos a 37 °C
orden = (
resumen_37
.sort_values("ph")
)
# Crear dos gráficas con la misma escala vertical
fig, ejes = plt.subplots(
1, 2,
figsize=(13, 5),
sharey=True
)
# Panel 1: media ± desviación estándar
ejes[0].bar(
orden["ph"],
orden["media"],
yerr=orden["de"],
capsize=5,
color="steelblue",
alpha=0.85
)
ejes[0].set_title("Media ± 1 DE")
ejes[0].set_xlabel("pH")
ejes[0].set_ylabel("Actividad enzimática (U/mL)")
ejes[0].set_xticks(orden["ph"])
# Panel 2: media ± error estándar
ejes[1].bar(
orden["ph"],
orden["media"],
yerr=orden["ee"],
capsize=5,
color="#2a9d9f",
alpha=0.85
)
ejes[1].set_title("Media ± 1 EE")
ejes[1].set_xlabel("pH")
ejes[1].set_xticks(orden["ph"])
# Título general y nota al pie
fig.suptitle(
"La barra izquierda se refiere a Desviación Estándar, mientras que la derecha a Error Estándar",
fontsize=18
)
fig.text(
0.5,
0.02,
"n = 3 réplicas biológicas por pH a 37 °C",
ha="center",
fontsize=11
)
plt.tight_layout()
plt.show()