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.
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.
Conserva intacto el archivo de datos crudos. Realiza la limpieza mediante código y guarda el resultado en un archivo nuevo.
Mantén los valores originales. Cuando corrijas o transformes una variable, conserva la columna original y crea una nueva columna con el resultado.
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.
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.
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.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
Importa el archivo crudo en datos_raw. Reporta dimensiones, columnas, tipos,
valores faltantes, duplicados exactos y duplicados del identificador
muestra_id.
# TODO 1: importa y audita la tabla.
# datos_raw = ...
# print(...)
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
print("Dimensiones iniciales:", datos_raw.shape)
Dimensiones iniciales: (91, 12)
La tabla tiene 91 filas porque una observación está duplicada.
print("Duplicados exactos:", datos_raw.duplicated().sum())
Duplicados exactos: 1
print("IDs duplicados:", datos_raw["muestra_id"].duplicated().sum())
IDs duplicados: 1
print("\nValores faltantes:")
Valores faltantes:
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
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
datos_raw.head()
| 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 |
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.
# TODO 2: muestra valores únicos y formula tu diagnóstico.
Paso 1. Consultar los tipos detectados por Python
columnas_diagnostico = [
"ph",
"temperatura_c",
"tiempo_reaccion_min",
"operador"
]
datos_raw[columnas_diagnostico].dtypes
| 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
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?
Crea datos como copia. Conserva las versiones originales de las columnas
problemáticas y genera columnas normalizadas:
ph_numtemperatura_c_numtiempo_reaccion_min_numoperador_limpioEl valor 120 s equivale a 2 minutos.
# TODO 3: transforma sin perder los valores originales.
Paso 1. Crear una copia de trabajo
datos = datos_raw.copy()
La limpieza se realizará sobre datos. El objeto datos_raw permanecerá intacto como respaldo de los datos originales.
# 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"]
| 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
# 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"]
| 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
# 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"]
| 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
# 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
# 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"
]]
| 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
Elimina solamente duplicados exactos. Después verifica:
# TODO 4: elimina duplicados exactos y crea indicadores de calidad.
En este reto realizaremos dos tareas diferentes:
Las validaciones se aplicarán sobre las columnas normalizadas creadas en el Reto 3.
Paso 1. Contar las filas antes de eliminar duplicados
n_antes = len(datos)
print("Filas antes de eliminar duplicados:", n_antes)
Filas antes de eliminar duplicados: 91
Paso 2. Identificar los duplicados exactos
es_duplicado_exacto = datos.duplicated(
subset=list(datos_raw.columns),
keep=False
)
datos.loc[es_duplicado_exacto, datos_raw.columns]
| 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
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:
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:
#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
#Identificadores repetidos
datos["flag_id_duplicado"] = datos[
"muestra_id"
].duplicated(keep=False)
datos["flag_id_duplicado"].sum()
np.int64(0)
keep=False marca todas las filas que comparten el mismo identificador.
Podemos crear una bandera general:
datos["flag_identificador"] = (
datos["flag_id_faltante"]
| datos["flag_id_duplicado"]
)
datos["flag_identificador"].sum()
np.int64(0)
Paso 5. Verificar que el pH esté entre 5 y 9
datos["flag_ph"] = (
datos["ph_num"].isna()
| ~datos["ph_num"].between(5, 9)
)
datos["flag_ph"].sum()
np.int64(0)
la bandera será:
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í |
datos["flag_temperatura"] = (
datos["temperatura_c_num"].isna()
| ~datos["temperatura_c_num"].isin([25, 37, 45])
)
datos["flag_temperatura"].sum()
np.int64(0)
Paso 7. Verificar el tiempo de reacción
El tiempo debe ser exactamente 2 minutos.
datos["flag_tiempo"] = ~np.isclose(
datos["tiempo_reaccion_min_num"],
2.0,
equal_nan=False
)
datos["flag_tiempo"].sum()
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
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:
datos["flag_absorbancia_final"] = (
datos["absorbancia_final_405_num"].isna()
| ~datos["absorbancia_final_405_num"].between(0, 2)
)
datos["flag_absorbancia_final"].sum()
np.int64(2)
Paso 10. Verificar el orden de las absorbancias: $A_{final} > A_{inicial}$
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()
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
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:
datos["requiere_revision"] = datos[
flags_validacion
].any(axis=1)
Muestra únicamente las filas donde requiere_revision sea True
datos[datos["requiere_revision"]]
| 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
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)
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
]
| 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 |
Importa registro_correcciones_laboratorio.csv. Aplica las correcciones por
muestra_id y campo. Documenta cuántos valores se corrigieron y de qué fuente
procedieron.
# TODO 5: aplica las correcciones verificadas. No imputes por promedio.
Paso 1. Importar el registro de correcciones
correcciones = pd.read_csv(
"registro_correcciones_laboratorio.csv",
encoding="utf-8-sig"
)
correcciones
| 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.
ids_corregidos = correcciones["muestra_id"]
datos.loc[
datos["muestra_id"].isin(ids_corregidos),
[
"muestra_id",
"absorbancia_inicial_405_num",
"absorbancia_final_405_num"
]
]
| 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
# 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
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
| 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
print(
"Número de valores corregidos:",
len(bitacora_calidad)
)
Número de valores corregidos: 2
Paso 6. Verificar los resultados
datos.loc[
datos["muestra_id"].isin(ids_corregidos),
[
"muestra_id",
"absorbancia_inicial_405_num",
"absorbancia_final_405_num"
]
]
| 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
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()
np.int64(0)
Comprobamos las dos muestras:
datos.loc[
datos["muestra_id"].isin(ids_corregidos),
[
"muestra_id",
"absorbancia_final_405_num",
"flag_absorbancia_final",
"flag_orden_absorbancias"
]
]
| 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 |
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.
# 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
datos["actividad_u_ml"] = (
(
datos["absorbancia_final_405_num"]
- datos["absorbancia_inicial_405_num"]
)
/ datos["tiempo_reaccion_min_num"]
) * 10
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.
# 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
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:
datos_limpios.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.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
conteos = pd.crosstab(datos_limpios['ph'], datos_limpios['temperatura_c'])
conteos
| 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:
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()
Exporta:
actividad_enzimatica_limpia_equipo.csvbitacora_calidad_equipo.csvEscribe una conclusión de 100 a 150 palabras que responda:
# TODO 8: exporta sin sobrescribir los datos crudos.
Escribe aquí tu conclusión.
Paso 1. Exportar los datos limpios
datos_limpios.to_csv(
"actividad_enzimatica_limpia_equipo.csv",
index=False,
encoding="utf-8-sig"
)
Paso 2. Exportar la bitácora de calidad
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.