Actividad integradora: auditar y limpiar datos de actividad enzimática¶

Curso: Herramientas Estadísticas y Computacionales para el Análisis de Datos en Investigación Bioquímica
Sesión 1: Introducción a Python y manejo de datos experimentales
Docente: Andrés García Medina
Contacto: andgarm.n@gmail.com

Tiempo sugerido: 60 minutos de trabajo + 30 minutos de discusión
Modalidad: parejas
Entregable: notebook completo, datos limpios, bitácora de datos y una breve conclusión.

Situación¶

Recibiste una tabla de absorbancia a 405 nm procedente de un experimento con cinco niveles de pH, tres temperaturas, tres réplicas biológicas y dos réplicas técnicas. La tabla contiene problemas de captura deliberados.

Tu objetivo no es construir un conjunto de datos limpios (preprocesados) trazable.
Nota: Los datos son sintéticos.

Criterios de trabajo¶

  1. Conserva intacto el archivo de datos crudos. Realiza la limpieza mediante código y guarda el resultado en un archivo nuevo.

  2. Mantén los valores originales. Cuando corrijas o transformes una variable, conserva la columna original y crea una nueva columna con el resultado.

  3. No adivines ni corrijas sin evidencia. Utiliza únicamente el protocolo, la bitácora o el registro de correcciones del laboratorio. Si no existe evidencia suficiente, conserva el valor como faltante y documenta el problema.

  4. Registra cada cambio en una bitácora de datos. Indica la muestra, la variable, el valor original, el valor corregido, la razón del cambio y la fuente que lo justifica.

  5. Verifica el diseño experimental después de la limpieza. Comprueba que continúen representados los tratamientos, las condiciones experimentales y las réplicas biológicas y técnicas esperadas.

In [1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

Reto 1. Importación e inventario¶

Importa el archivo crudo en datos_raw. Reporta dimensiones, columnas, tipos, valores faltantes, duplicados exactos y duplicados del identificador muestra_id.

In [2]:
# TODO 1: importa y audita la tabla.
# datos_raw = ...
# print(...)
In [3]:
datos_raw = pd.read_csv("datos_actividad_enzimatica_crudos.csv", sep=",",encoding="utf-8-sig", dtype={"muestra_id": "string"})

Puede verificar el siguiente enlace para más inforamciónn del método $\texttt{read_csv}$: https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html

In [4]:
print("Dimensiones iniciales:", datos_raw.shape)
Dimensiones iniciales: (91, 12)

La tabla tiene 91 filas porque una observación está duplicada.

In [5]:
print("Duplicados exactos:", datos_raw.duplicated().sum())
Duplicados exactos: 1
In [6]:
print("IDs duplicados:", datos_raw["muestra_id"].duplicated().sum())
IDs duplicados: 1
In [7]:
print("\nValores faltantes:")
Valores faltantes:
In [8]:
print(datos_raw.isna().sum())
muestra_id                  0
fecha                       0
lote_enzima                 0
operador                    0
ph                          0
temperatura_c               0
replica_biologica           0
replica_tecnica             0
tiempo_reaccion_min         0
absorbancia_inicial_405     0
absorbancia_final_405       1
observacion                91
dtype: int64
In [9]:
print("\nTipos:")
print(datos_raw.dtypes)
Tipos:
muestra_id                 string[python]
fecha                              object
lote_enzima                        object
operador                           object
ph                                 object
temperatura_c                      object
replica_biologica                   int64
replica_tecnica                     int64
tiempo_reaccion_min                object
absorbancia_inicial_405           float64
absorbancia_final_405              object
observacion                       float64
dtype: object
In [10]:
datos_raw.head()
Out[10]:
muestra_id fecha lote_enzima operador ph temperatura_c replica_biologica replica_tecnica tiempo_reaccion_min absorbancia_inicial_405 absorbancia_final_405 observacion
0 AE-PH5-T25-B1-R1 2026-07-20 L01 Ana 5 25 1 1 2.0 0.0496 0.1301 NaN
1 AE-PH5-T25-B1-R2 2026-07-20 L01 Ana 5 25 1 2 2.0 0.0482 0.1331 NaN
2 AE-PH5-T25-B2-R1 2026-07-21 L02 Luis 5 25 2 1 2.0 0.0398 0.1244 NaN
3 AE-PH5-T25-B2-R2 2026-07-21 L02 Luis 5 25 2 2 2.0 0.0361 0.1205 NaN
4 AE-PH5-T25-B3-R1 2026-07-22 L03 Ana pH 5 25 3 1 2.0 0.0427 0.1392 NaN

Reto 2. Diagnóstico de categorías y formatos¶

En una base de laboratorio, una columna puede parecer numérica y, aun así, ser importada como texto. Esto sucede cuando contiene unidades, espacios, comas decimales o anotaciones.

Examina valores únicos de ph, temperatura_c, tiempo_reaccion_min y operador. Explica por qué las columnas que deberían ser numéricas pueden haber sido interpretadas como texto.

In [11]:
# TODO 2: muestra valores únicos y formula tu diagnóstico.

Paso 1. Consultar los tipos detectados por Python

In [12]:
columnas_diagnostico = [
    "ph",
    "temperatura_c",
    "tiempo_reaccion_min",
    "operador"
]

datos_raw[columnas_diagnostico].dtypes
Out[12]:
0
ph object
temperatura_c object
tiempo_reaccion_min object
operador object

El tipo object o string indica que la columna contiene texto o una combinación de formatos. Una variable experimentalmente numérica no necesariamente fue almacenada como número.

Paso 2. Examinar los valores únicos

In [13]:
for columna in columnas_diagnostico:
    print(f"\nValores de {columna}:")
    print(datos_raw[columna].unique())
Valores de ph:
['5' 'pH 5' 'ph_5' '6' '6.0' '7' 'pH=7' '8' 'PH 8' '9' '9 ']

Valores de temperatura_c:
['25' '37' '37 °C' '45' '37C' '25 C' ' 25 ']

Valores de tiempo_reaccion_min:
['2.0' '120 s']

Valores de operador:
['Ana' 'Luis' ' ana ' 'LUIS' 'Ana ']

Paso 3. Identificar los problemas

¿Por qué una sola celda como 37 °C puede hacer que toda la columna sea interpretada como texto, aunque las demás celdas contengan números?

Reto 3. Limpieza reproducible¶

Crea datos como copia. Conserva las versiones originales de las columnas problemáticas y genera columnas normalizadas:

  • ph_num
  • temperatura_c_num
  • tiempo_reaccion_min_num
  • operador_limpio
  • absorbancias numéricas con soporte para coma decimal

El valor 120 s equivale a 2 minutos.

In [14]:
# TODO 3: transforma sin perder los valores originales.

Paso 1. Crear una copia de trabajo

In [15]:
datos = datos_raw.copy()

La limpieza se realizará sobre datos. El objeto datos_raw permanecerá intacto como respaldo de los datos originales.

  1. Normalizar el pH
In [16]:
# 2.1. Conservar los valores originales
datos["ph_original"] = datos["ph"]

# 2.2. Convertir todos los valores a texto
ph_texto = datos["ph"].astype("string")

# 2.3. Eliminar espacios al principio y al final
ph_texto = ph_texto.str.strip()

# 2.4. Homologar mayúsculas y minúsculas
ph_texto = ph_texto.str.lower()

# 2.5. Eliminar la etiqueta "ph"
ph_texto = ph_texto.str.replace("ph", "", regex=False)

# 2.6. Eliminar separadores como "_" y "="
ph_texto = ph_texto.str.replace("_", "", regex=False)
ph_texto = ph_texto.str.replace("=", "", regex=False)

# 2.7. Eliminar nuevamente posibles espacios
ph_texto = ph_texto.str.strip()

# 2.8. Convertir el resultado a número
datos["ph_num"] = pd.to_numeric(
    ph_texto,
    errors="coerce"
)

#2.9 Imprimir para verificar
datos["ph_num"]
Out[16]:
ph_num
0 5.0
1 5.0
2 5.0
3 5.0
4 5.0
... ...
86 9.0
87 9.0
88 9.0
89 9.0
90 5.0

91 rows × 1 columns


  1. Normalizar temperatura_c
In [17]:
# 3.1. Conservar los valores originales
datos["temperatura_c_original"] = datos["temperatura_c"]

# 3.2. Convertir todos los valores a texto
temperatura_texto = datos["temperatura_c"].astype("string")

# 3.3. Eliminar espacios al principio y al final
temperatura_texto = temperatura_texto.str.strip()

# 3.4. Homologar el separador decimal
temperatura_texto = temperatura_texto.str.replace(
    ",",
    ".",
    regex=False
)

# 3.5. Extraer la parte numérica
temperatura_extraida = temperatura_texto.str.extract(
    r"([-+]?\d+(?:\.\d+)?)",
    expand=False
)

# 3.6. Convertir el resultado a número
datos["temperatura_c_num"] = pd.to_numeric(
    temperatura_extraida,
    errors="coerce"
)

#3.7 Imprimir para verificar
datos["temperatura_c_num"]
Out[17]:
temperatura_c_num
0 25
1 25
2 25
3 25
4 25
... ...
86 45
87 45
88 45
89 45
90 37

91 rows × 1 columns


  1. Normalizar tiempo_reaccion_min
In [18]:
# 4.1. Conservar los valores originales
datos["tiempo_reaccion_min_original"] = (
    datos["tiempo_reaccion_min"]
)

# 4.2. Preparar el texto
tiempo_texto = (
    datos["tiempo_reaccion_min"]
    .astype("string")
    .str.strip()
    .str.lower()
    .str.replace(",", ".", regex=False)
)

# 4.3. Extraer la parte numérica
tiempo_extraido = tiempo_texto.str.extract(
    r"([-+]?\d+(?:\.\d+)?)",
    expand=False
)

# 4.4. Convertir a número
datos["tiempo_reaccion_min_num"] = pd.to_numeric(
    tiempo_extraido,
    errors="coerce"
)

# 4.5. Identificar los valores expresados en segundos
es_segundos = tiempo_texto.str.contains(
    r"\bsegundos?\b|\bsegs?\b|\bs\b",
    regex=True,
    na=False
)

# 4.6. Convertir segundos a minutos
datos.loc[es_segundos,"tiempo_reaccion_min_num"] = (datos.loc[es_segundos,"tiempo_reaccion_min_num"] / 60)

#4.7 Imprimir para verificar
datos["tiempo_reaccion_min_num"]
Out[18]:
tiempo_reaccion_min_num
0 2.0
1 2.0
2 2.0
3 2.0
4 2.0
... ...
86 2.0
87 2.0
88 2.0
89 2.0
90 2.0

91 rows × 1 columns


  1. Normalizar operador
In [19]:
# 5.1. Conservar los valores originales
datos["operador_original"] = datos["operador"]

# 5.2. Convertir todos los valores a texto
operador_texto = datos["operador"].astype("string")

# 5.3. Eliminar espacios al principio y al final
operador_texto = operador_texto.str.strip()

# 454. Reemplazar espacios repetidos por un solo espacio
operador_texto = operador_texto.str.replace(
    r"\s+",
    " ",
    regex=True
)

# 5.5. Homologar mayúsculas y minúsculas
operador_texto = operador_texto.str.title()

# 5.6. Crear la columna normalizada
datos["operador_limpio"] = operador_texto

# 5.7. Imprimir para verificar
print("Valores originales:")
print(datos["operador_original"].unique())

print("\nValores normalizados:")
print(datos["operador_limpio"].unique())
Valores originales:
['Ana' 'Luis' ' ana ' 'LUIS' 'Ana ']

Valores normalizados:
<StringArray>
['Ana', 'Luis']
Length: 2, dtype: string
  1. Normalizar absorbancia
In [20]:
# 6.1. Conservar los valores originales
datos["absorbancia_inicial_405_original"] = (
    datos["absorbancia_inicial_405"]
)

datos["absorbancia_final_405_original"] = (
    datos["absorbancia_final_405"]
)

# 6.2. Preparar la absorbancia inicial
absorbancia_inicial_texto = (
    datos["absorbancia_inicial_405"]
    .astype("string")
    .str.strip()
    .str.replace(",", ".", regex=False)
)

# 6.3. Convertir la absorbancia inicial a número
datos["absorbancia_inicial_405_num"] = pd.to_numeric(
    absorbancia_inicial_texto,
    errors="coerce"
)

# 6.4. Preparar la absorbancia final
absorbancia_final_texto = (
    datos["absorbancia_final_405"]
    .astype("string")
    .str.strip()
    .str.replace(",", ".", regex=False)
)

# 6.5. Convertir la absorbancia final a número
datos["absorbancia_final_405_num"] = pd.to_numeric(
    absorbancia_final_texto,
    errors="coerce"
)

# 6.6. Imprimir para verificar
datos[[
    "absorbancia_inicial_405_num",
    "absorbancia_final_405_num"
]]
Out[20]:
absorbancia_inicial_405_num absorbancia_final_405_num
0 0.0496 0.1301
1 0.0482 0.1331
2 0.0398 0.1244
3 0.0361 0.1205
4 0.0427 0.1392
... ... ...
86 0.0434 0.1293
87 0.0495 0.1398
88 0.0483 0.1405
89 0.0477 0.1388
90 0.0496 0.1619

91 rows × 2 columns

Reto 4. Duplicados y validaciones¶

Elimina solamente duplicados exactos. Después verifica:

  • identificador único;
  • pH entre 5 y 9;
  • temperatura en {25, 37, 45} °C;
  • tiempo de reacción igual a 2 min;
  • absorbancias entre 0 y 2 UA;
  • absorbancia final mayor que la inicial.
In [21]:
# TODO 4: elimina duplicados exactos y crea indicadores de calidad.

En este reto realizaremos dos tareas diferentes:

  • eliminar únicamente las filas completamente idénticas;
  • verificar las reglas de calidad sin eliminar automáticamente los registros que no las cumplen.

Las validaciones se aplicarán sobre las columnas normalizadas creadas en el Reto 3.

Paso 1. Contar las filas antes de eliminar duplicados

In [22]:
n_antes = len(datos)

print("Filas antes de eliminar duplicados:", n_antes)
Filas antes de eliminar duplicados: 91

Paso 2. Identificar los duplicados exactos

In [23]:
es_duplicado_exacto = datos.duplicated(
    subset=list(datos_raw.columns),
    keep=False
)

datos.loc[es_duplicado_exacto, datos_raw.columns]
Out[23]:
muestra_id fecha lote_enzima operador ph temperatura_c replica_biologica replica_tecnica tiempo_reaccion_min absorbancia_inicial_405 absorbancia_final_405 observacion
10 AE-PH5-T37-B3-R1 2026-07-22 L03 Ana 5 37 3 1 2.0 0.0496 0.1619 NaN
90 AE-PH5-T37-B3-R1 2026-07-22 L03 Ana 5 37 3 1 2.0 0.0496 0.1619 NaN

El argumento keep=False permite mostrar todas las copias de cada fila repetida.

Paso 3. Eliminar solamente los duplicados exactos

In [24]:
datos = datos.drop_duplicates(
    subset=list(datos_raw.columns),
    keep="first"
).copy()

keep="first" conserva la primera aparición y elimina las copias posteriores.

Calculamos cuántas filas se eliminaron:

In [25]:
n_duplicados_eliminados = n_antes - len(datos)

print(
    "Duplicados exactos eliminados:",
    n_duplicados_eliminados
)

print(
    "Filas después de eliminar duplicados:",
    len(datos)
)
Duplicados exactos eliminados: 1
Filas después de eliminar duplicados: 90

Paso 4. Verificar el identificador

Un identificador presenta una incidencia si:

  • está ausente o vacío; o
  • aparece en más de una fila después de eliminar duplicados exactos.
In [26]:
#Identificadores faltantes
datos["flag_id_faltante"] = (
    datos["muestra_id"].isna()
    | datos["muestra_id"]
        .astype("string")
        .str.strip()
        .eq("")
)
print(datos["flag_id_faltante"].sum())
0
In [27]:
#Identificadores repetidos
datos["flag_id_duplicado"] = datos[
    "muestra_id"
].duplicated(keep=False)
datos["flag_id_duplicado"].sum()
Out[27]:
np.int64(0)

keep=False marca todas las filas que comparten el mismo identificador.

Podemos crear una bandera general:

In [28]:
datos["flag_identificador"] = (
    datos["flag_id_faltante"]
    | datos["flag_id_duplicado"]
)
datos["flag_identificador"].sum()
Out[28]:
np.int64(0)

Paso 5. Verificar que el pH esté entre 5 y 9

In [29]:
datos["flag_ph"] = (
    datos["ph_num"].isna()
    | ~datos["ph_num"].between(5, 9)
)
datos["flag_ph"].sum()
Out[29]:
np.int64(0)

la bandera será:

  • False: el valor cumple la regla;
  • True: el valor está ausente o fuera del intervalo.

Los límites 5 y 9 se consideran válidos porque .between(5, 9) incluye ambos extremos.

Paso 6. Verificar la temperatura

Las únicas temperaturas permitidas son:

$T\in\{25,37,45\}$

Por ejemplo

Temperatura ¿Presenta incidencia?
25 No
37 No
45 No
30 Sí
Ausente Sí
In [30]:
datos["flag_temperatura"] = (
    datos["temperatura_c_num"].isna()
    | ~datos["temperatura_c_num"].isin([25, 37, 45])
)

datos["flag_temperatura"].sum()
Out[30]:
np.int64(0)

Paso 7. Verificar el tiempo de reacción

El tiempo debe ser exactamente 2 minutos.

In [31]:
datos["flag_tiempo"] = ~np.isclose(
    datos["tiempo_reaccion_min_num"],
    2.0,
    equal_nan=False
)
datos["flag_tiempo"].sum()
Out[31]:
np.int64(0)

Se utiliza np.isclose() porque los números decimales pueden contener pequeñas diferencias internas de precisión.

Con equal_nan=False, los valores ausentes también se consideran incidencias.

Por ejemplo, después de la normalización:

Valor original Valor normalizado ¿Es válido?
2.0 2.0 min Sí
120 s 2.0 min Sí
3.0 3.0 min No

Paso 8. Verificar la absorbancia inicial

La absorbancia inicial debe encontrarse entre 0 y 2 UA

In [32]:
datos["flag_absorbancia_inicial"] = (
    datos["absorbancia_inicial_405_num"].isna()
    | ~datos["absorbancia_inicial_405_num"].between(0, 2)
)

print(datos["flag_absorbancia_inicial"].sum())
0

Paso 9. Verificar la absorbancia final

La misma regla se aplica a la absorbancia final:

In [33]:
datos["flag_absorbancia_final"] = (
    datos["absorbancia_final_405_num"].isna()
    | ~datos["absorbancia_final_405_num"].between(0, 2)
)

datos["flag_absorbancia_final"].sum()
Out[33]:
np.int64(2)

Paso 10. Verificar el orden de las absorbancias: $A_{final} > A_{inicial}$

In [34]:
datos["flag_orden_absorbancias"] = (
    datos["absorbancia_inicial_405_num"].notna()
    & datos["absorbancia_final_405_num"].notna()
    & (
        datos["absorbancia_final_405_num"]
        <= datos["absorbancia_inicial_405_num"]
    )
)

datos["flag_orden_absorbancias"].sum()
Out[34]:
np.int64(0)

Se utiliza <= porque son incorrectos ambos casos:

-la absorbancia final es menor que la inicial;

-las dos absorbancias son iguales.

Los valores ausentes ya quedan registrados mediante las banderas individuales de absorbancia.

Paso 11. Reunir las banderas principales

In [35]:
flags_validacion = [
    "flag_identificador",
    "flag_ph",
    "flag_temperatura",
    "flag_tiempo",
    "flag_absorbancia_inicial",
    "flag_absorbancia_final",
    "flag_orden_absorbancias"
]

Creamos una columna que indique si cada registro presenta al menos una incidencia:

In [36]:
datos["requiere_revision"] = datos[
    flags_validacion
].any(axis=1)

Muestra únicamente las filas donde requiere_revision sea True

In [37]:
datos[datos["requiere_revision"]]
Out[37]:
muestra_id fecha lote_enzima operador ph temperatura_c replica_biologica replica_tecnica tiempo_reaccion_min absorbancia_inicial_405 ... flag_id_faltante flag_id_duplicado flag_identificador flag_ph flag_temperatura flag_tiempo flag_absorbancia_inicial flag_absorbancia_final flag_orden_absorbancias requiere_revision
51 AE-PH7-T45-B2-R2 2026-07-21 L02 Luis 7 45 2 2 2.0 0.0397 ... False False False False False False False True False True
68 AE-PH8-T45-B2-R1 2026-07-21 L02 Luis 8 45 2 1 2.0 0.0497 ... False False False False False False False True False True

2 rows × 34 columns

Paso 12. Contar las incidencias

In [38]:
print(
    "Duplicados exactos eliminados:",
    n_duplicados_eliminados
)

print("\nNúmero de incidencias por validación:")

print(
    datos[flags_validacion]
    .sum()
    .sort_values(ascending=False)
)
Duplicados exactos eliminados: 1

Número de incidencias por validación:
flag_absorbancia_final      2
flag_ph                     0
flag_identificador          0
flag_temperatura            0
flag_tiempo                 0
flag_absorbancia_inicial    0
flag_orden_absorbancias     0
dtype: Int64

Paso 13. Mostrar solamente los registros problemáticos (alternativo)

In [39]:
columnas_revision = [
    "muestra_id",
    "ph_original",
    "ph_num",
    "temperatura_c_original",
    "temperatura_c_num",
    "tiempo_reaccion_min_original",
    "tiempo_reaccion_min_num",
    "absorbancia_inicial_405_original",
    "absorbancia_inicial_405_num",
    "absorbancia_final_405_original",
    "absorbancia_final_405_num",
    *flags_validacion
]

datos.loc[
    datos["requiere_revision"],
    columnas_revision
]
Out[39]:
muestra_id ph_original ph_num temperatura_c_original temperatura_c_num tiempo_reaccion_min_original tiempo_reaccion_min_num absorbancia_inicial_405_original absorbancia_inicial_405_num absorbancia_final_405_original absorbancia_final_405_num flag_identificador flag_ph flag_temperatura flag_tiempo flag_absorbancia_inicial flag_absorbancia_final flag_orden_absorbancias
51 AE-PH7-T45-B2-R2 7 7.0 45 45 2.0 2.0 0.0397 0.0397 NaN <NA> False False False False False True False
68 AE-PH8-T45-B2-R1 8 8.0 45 45 2.0 2.0 0.0497 0.0497 2.504 2.504 False False False False False True False

Reto 5. Correcciones con fuente primaria¶

Importa registro_correcciones_laboratorio.csv. Aplica las correcciones por muestra_id y campo. Documenta cuántos valores se corrigieron y de qué fuente procedieron.

In [40]:
# TODO 5: aplica las correcciones verificadas. No imputes por promedio.

Paso 1. Importar el registro de correcciones

In [41]:
correcciones = pd.read_csv(
    "registro_correcciones_laboratorio.csv",
    encoding="utf-8-sig"
)

correcciones
Out[41]:
muestra_id campo valor_corregido fuente motivo
0 AE-PH7-T45-B2-R2 absorbancia_final_405 0.3033 Bitácora espectrofotómetro EQ-405, corrida 202... Valor omitido durante la transcripción
1 AE-PH8-T45-B2-R1 absorbancia_final_405 0.2504 Bitácora espectrofotómetro EQ-405, corrida 202... Punto decimal desplazado durante la captura

El archivo contiene las siguientes columnas:

Columna Descripción
muestra_id Identificador de la muestra
campo Variable que debe corregirse
valor_corregido Valor confirmado en la fuente primaria
fuente Documento o bitácora consultada
motivo Explicación de la incidencia

Paso 2. Mostrar los valores actuales. Así podemos comparar los datos actuales con la fuente primaria antes de corregirlos.

In [42]:
ids_corregidos = correcciones["muestra_id"]

datos.loc[
    datos["muestra_id"].isin(ids_corregidos),
    [
        "muestra_id",
        "absorbancia_inicial_405_num",
        "absorbancia_final_405_num"
    ]
]
Out[42]:
muestra_id absorbancia_inicial_405_num absorbancia_final_405_num
51 AE-PH7-T45-B2-R2 0.0397 <NA>
68 AE-PH8-T45-B2-R1 0.0497 2.504

Paso 3. Aplicar las correcciones semimanualmente

In [43]:
# Primera corrección: valor omitido
datos.loc[
    datos["muestra_id"] == "AE-PH7-T45-B2-R2",
    "absorbancia_final_405_num"
] = 0.3033


# Segunda corrección: punto decimal desplazado
datos.loc[
    datos["muestra_id"] == "AE-PH8-T45-B2-R1",
    "absorbancia_final_405_num"
] = 0.2504

Las correcciones se aplican sobre la columna numérica empleada en el análisis. La columna original se conserva sin cambios como evidencia de la captura inicial.

Paso 4. Crear una bitácora sencilla

In [44]:
bitacora_calidad = pd.DataFrame({
    "muestra_id": [
        "AE-PH7-T45-B2-R2",
        "AE-PH8-T45-B2-R1"
    ],
    "campo_modificado": [
        "absorbancia_final_405_num",
        "absorbancia_final_405_num"
    ],
    "valor_anterior": [
        np.nan,
        2.504
    ],
    "valor_corregido": [
        0.3033,
        0.2504
    ],
    "fuente": [
        "Bitácora espectrofotómetro EQ-405, corrida 2026-07-22",
        "Bitácora espectrofotómetro EQ-405, corrida 2026-07-22"
    ],
    "motivo": [
        "Valor omitido durante la transcripción",
        "Punto decimal desplazado durante la captura"
    ]
})

bitacora_calidad
Out[44]:
muestra_id campo_modificado valor_anterior valor_corregido fuente motivo
0 AE-PH7-T45-B2-R2 absorbancia_final_405_num NaN 0.3033 Bitácora espectrofotómetro EQ-405, corrida 202... Valor omitido durante la transcripción
1 AE-PH8-T45-B2-R1 absorbancia_final_405_num 2.504 0.2504 Bitácora espectrofotómetro EQ-405, corrida 202... Punto decimal desplazado durante la captura

Paso 5. Documentar cuántos valores se corrigieron

In [45]:
print(
    "Número de valores corregidos:",
    len(bitacora_calidad)
)
Número de valores corregidos: 2

Paso 6. Verificar los resultados

In [46]:
datos.loc[
    datos["muestra_id"].isin(ids_corregidos),
    [
        "muestra_id",
        "absorbancia_inicial_405_num",
        "absorbancia_final_405_num"
    ]
]
Out[46]:
muestra_id absorbancia_inicial_405_num absorbancia_final_405_num
51 AE-PH7-T45-B2-R2 0.0397 0.3033
68 AE-PH8-T45-B2-R1 0.0497 0.2504

Paso 7. Repetir las validaciones afectadas

In [47]:
datos["flag_absorbancia_final"] = (
    datos["absorbancia_final_405_num"].isna()
    | ~datos["absorbancia_final_405_num"].between(0, 2)
)

datos["flag_orden_absorbancias"] = (
    datos["absorbancia_final_405_num"].isna()
    | datos["absorbancia_inicial_405_num"].isna()
    | (
        datos["absorbancia_final_405_num"]
        <= datos["absorbancia_inicial_405_num"]
    )
)

datos["flag_absorbancia_final"].sum()
Out[47]:
np.int64(0)

Comprobamos las dos muestras:

In [48]:
datos.loc[
    datos["muestra_id"].isin(ids_corregidos),
    [
        "muestra_id",
        "absorbancia_final_405_num",
        "flag_absorbancia_final",
        "flag_orden_absorbancias"
    ]
]
Out[48]:
muestra_id absorbancia_final_405_num flag_absorbancia_final flag_orden_absorbancias
51 AE-PH7-T45-B2-R2 0.3033 False False
68 AE-PH8-T45-B2-R1 0.2504 False False

Reto 6. Variable de respuesta¶

Calcula:

$ \text{actividad (U/mL)} = \frac{A_{final}-A_{inicial}}{\text{tiempo (min)}}\times 10 $

La constante 10 es una escala didáctica de este conjunto sintético.

In [49]:
# TODO 6: crea actividad_u_ml y confirma que no existan valores faltantes.

Paso 1. Identificar las columnas que se utilizarán:

Variable Columna
Absorbancia inicial absorbancia_inicial_405_num
Absorbancia final absorbancia_final_405_num
Tiempo de reacción tiempo_reaccion_min_num
Actividad calculada actividad_u_ml

Paso 2. Aplicar formula

In [50]:
datos["actividad_u_ml"] = (
    (
        datos["absorbancia_final_405_num"]
        - datos["absorbancia_inicial_405_num"]
    )
    / datos["tiempo_reaccion_min_num"]
) * 10

Reto 7. Comprobar el diseño y visualizar¶

Debe haber seis mediciones por combinación de pH y temperatura (3 biológicas × 2 técnicas). Construye una tabla de conteos y una gráfica exploratoria de actividad por pH, separada por temperatura.

In [51]:
# TODO 7: tabla de conteos y gráfica.

Primero construiremos una tabla final con nombres sencillos. Conservaremos únicamente las variables necesarias para documentar y analizar el experimento.

Paso 1. Crear el conjunto de datos limpio

In [52]:
columnas_salida = [
    "muestra_id",
    "fecha",
    "lote_enzima",
    "operador_limpio",
    "ph_num",
    "temperatura_c_num",
    "replica_biologica",
    "replica_tecnica",
    "tiempo_reaccion_min_num",
    "absorbancia_inicial_405_num",
    "absorbancia_final_405_num",
    "actividad_u_ml",
    "observacion",
]

datos_limpios = (
    datos[columnas_salida]
    .rename(columns={
        "operador_limpio": "operador",
        "ph_num": "ph",
        "temperatura_c_num": "temperatura_c",
        "tiempo_reaccion_min_num": "tiempo_reaccion_min",
        "absorbancia_inicial_405_num": "absorbancia_inicial_405",
        "absorbancia_final_405_num": "absorbancia_final_405",
    })
    .copy()
)

Revisamos el resultado:

In [53]:
datos_limpios.head()
Out[53]:
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.423 NaN
3 AE-PH5-T25-B2-R2 2026-07-21 L02 Luis 5.0 25 2 2 2.0 0.0361 0.1205 0.422 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

Paso 2. Construir la tabla de conteos

In [54]:
conteos = pd.crosstab(datos_limpios['ph'], datos_limpios['temperatura_c'])

conteos
Out[54]:
temperatura_c 25 37 45
ph
5.0 6 6 6
6.0 6 6 6
7.0 6 6 6
8.0 6 6 6
9.0 6 6 6

Paso 2: 5. Crear la gráfica exploratoria

Usaremos un diagrama de caja para comparar la distribución de la actividad entre niveles de pH y temperaturas:

In [55]:
import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid")

fig, ax = plt.subplots(figsize=(8, 4.5))

sns.boxplot(
    data=datos_limpios,
    x="ph",
    y="actividad_u_ml",
    hue="temperatura_c",
    ax=ax,
)

ax.set(
    title="Control exploratorio después de la limpieza",
    xlabel="pH",
    ylabel="Actividad (U/mL)",
)

ax.legend(
    title="Temperatura (°C)",
    bbox_to_anchor=(1.02, 1),
    loc="upper left",
)

plt.tight_layout()
plt.show()

Reto 8. Exportación y conclusión¶

Exporta:

  • actividad_enzimatica_limpia_equipo.csv
  • bitacora_calidad_equipo.csv

Escribe una conclusión de 100 a 150 palabras que responda:

  1. ¿Qué problemas habrían sesgado o impedido el análisis?
  2. ¿Qué decisiones fueron automáticas y cuáles requirieron una fuente primaria?
  3. ¿Qué evidencia demuestra que el diseño experimental quedó completo?
In [56]:
# TODO 8: exporta sin sobrescribir los datos crudos.

Conclusión del equipo¶

Escribe aquí tu conclusión.

Paso 1. Exportar los datos limpios

In [57]:
datos_limpios.to_csv(
    "actividad_enzimatica_limpia_equipo.csv",
    index=False,
    encoding="utf-8-sig"
)

Paso 2. Exportar la bitácora de calidad

In [58]:
bitacora_calidad.to_csv(
    "bitacora_calidad_equipo.csv",
    index=False,
    encoding="utf-8-sig"
)

El parámetro index=False evita agregar una columna innecesaria con el índice de pandas. La codificación utf-8-sig facilita abrir los archivos en Excel conservando correctamente acentos y caracteres especiales.