BioRAG / models.py
C2MV's picture
Upload 8 files
d3d8124 verified
raw
history blame
11.3 kB
# models.py
import numpy as np
from scipy.optimize import curve_fit
from sympy import symbols, sympify, lambdify
import warnings
from sklearn.metrics import mean_squared_error # Asegúrate que está importado
class BioprocessModel:
def __init__(self):
self.params = {} # Almacenará los parámetros ajustados para cada tipo de modelo
self.models = {} # Almacenará la configuración de cada tipo de modelo (ecuación, función, etc.)
self.r2 = {} # Almacenará el R^2 para cada tipo de modelo
self.rmse = {} # Almacenará el RMSE para cada tipo de modelo
def set_model(self, model_type, equation_str, param_str):
"""
Configura un modelo (Biomasa, Sustrato, Producto) usando una ecuación simbólica.
:param model_type: 'biomass', 'substrate', o 'product'
:param equation_str: La ecuación como una cadena de texto (ej. "Xm * (1 - exp(-um * t))")
Si la ecuación es para sustrato o producto y depende de la biomasa X(t),
se debe usar 'X_val' en la ecuación para representar el valor de X(t).
:param param_str: Cadena de parámetros separados por comas (ej. "Xm, um")
"""
equation_str = equation_str.strip()
# Si el usuario escribe "Y = ...", tomar solo la parte derecha
if '=' in equation_str:
equation_str = equation_str.split('=', 1)[1].strip()
params_list = [param.strip() for param in param_str.split(',')]
self.models[model_type] = {
'equation_str': equation_str,
'params': params_list
}
# Símbolos para el tiempo y los parámetros del modelo actual
t_sym = symbols('t')
current_param_syms = symbols(params_list)
# Argumentos para lambdify
lambdify_args = [t_sym] + list(current_param_syms)
# Si el modelo es sustrato o producto, puede depender de la biomasa (X_val)
# y de los parámetros de biomasa ajustados.
# La función `lambdify` solo manejará los parámetros directos de este modelo.
# La dependencia de X(t) se resolverá en la función de ajuste.
# Si la ecuación contiene 'X_val' (representando X(t) para modelos S y P)
# lo añadimos como un símbolo que se sustituirá en la función de ajuste.
all_symbols_in_eq = sympify(equation_str).free_symbols
final_lambdify_args = list(lambdify_args) # Copia para modificar
# Manejo de X_val para modelos S y P que dependen de biomasa
# No es necesario añadir X_val a lambdify_args aquí si se maneja en fit_model_wrapper
# func = lambdify(final_lambdify_args, sympify(equation_str), 'numpy')
# Guardar los símbolos para uso posterior si es necesario
self.models[model_type]['sympy_expr'] = sympify(equation_str)
self.models[model_type]['param_symbols'] = current_param_syms
self.models[model_type]['time_symbol'] = t_sym
# La función compilada ('function') se creará dinámicamente en fit_model para manejar dependencias
def fit_model(self, model_type, time, data, bounds, biomass_params_fitted=None):
"""
Ajusta el modelo configurado a los datos.
:param model_type: 'biomass', 'substrate', o 'product'
:param time: Array de datos de tiempo
:param data: Array de datos observados
:param bounds: Tupla (lower_bounds, upper_bounds) para los parámetros
:param biomass_params_fitted: Dict de parámetros de biomasa ajustados (necesario para sustrato/producto)
:return: (y_pred, popt) - Datos predichos y parámetros optimizados
"""
if model_type not in self.models:
raise ValueError(f"Modelo para '{model_type}' no configurado. Llama a set_model primero.")
model_config = self.models[model_type]
equation_expr = model_config['sympy_expr']
current_param_names = model_config['params']
current_param_syms = model_config['param_symbols']
t_sym = model_config['time_symbol']
# Construir la función a ajustar con curve_fit
# Esta función tomará `t` y los parámetros del modelo actual como `*args`
def fit_model_wrapper(t_data, *current_p_values):
# Crear un diccionario de sustitución para los parámetros del modelo actual
subs_dict = {sym: val for sym, val in zip(current_param_syms, current_p_values)}
# Si es sustrato o producto y depende de la biomasa (X_val)
X_val_sym = symbols('X_val') # Símbolo para X(t)
if model_type in ['substrate', 'product'] and X_val_sym in equation_expr.free_symbols:
if biomass_params_fitted is None or 'biomass' not in self.models:
raise ValueError("Parámetros de biomasa ajustados son necesarios para modelos de sustrato/producto que dependen de X(t).")
# Calcular X(t) usando el modelo de biomasa ajustado y los t_data actuales
biomass_model_config = self.models['biomass']
biomass_expr = biomass_model_config['sympy_expr']
biomass_p_syms = biomass_model_config['param_symbols']
# Crear dict de sustitución para parámetros de biomasa
biomass_subs_dict = {sym: biomass_params_fitted[name] for sym, name in zip(biomass_p_syms, biomass_model_config['params'])}
# Evaluar X(t) para cada punto en t_data
X_t_values = np.array([
biomass_expr.subs(biomass_subs_dict).subs({biomass_model_config['time_symbol']: ti}).evalf()
for ti in t_data
], dtype=float)
# Añadir X_val (X(t)) al diccionario de sustituciones para la ecuación actual
subs_dict[X_val_sym] = X_t_values
# Evaluar la ecuación del modelo actual (sustrato, producto, o biomasa)
# con los valores de sus parámetros y X(t) si es aplicable
# Es importante que la ecuación use t_sym como variable independiente
# y que lambdify se haga sobre (t_sym, *current_param_syms, X_val_sym_if_present)
# Re-lambdify aquí para asegurar el scope correcto de X_t_values si se usa X_val_sym
args_for_lambdify = [t_sym] + list(current_param_syms)
final_expr = equation_expr # Expresión original
# Si X_val está en la ecuación, NO lo incluimos en args_for_lambdify
# sino que lo sustituimos ANTES de lambdify o lo pasamos como constante si es posible.
# Lo más robusto es sustituir X_val en la expresión ANTES de lambdify
if X_val_sym in equation_expr.free_symbols and X_val_sym in subs_dict:
# Si X_val es un array (porque t_data es un array), no podemos sustituirlo directamente
# en la expresión simbólica para crear una única función lambdify que tome `t`.
# En su lugar, evaluamos la expresión punto por punto después de sustituir parámetros.
# Esto es menos eficiente que un lambdify completo, pero más flexible.
# Evaluación numérica punto por punto
# No necesitamos lambdify si X_val es un array y se evalúa punto por punto
# La función `fit_model_wrapper` ya está iterando sobre los `t_data` (implícitamente a través de numpy)
# Entonces, si X_val es un array, la expresión `final_expr.subs(subs_dict)` debería funcionar si
# las operaciones son compatibles con numpy arrays (sympy suele hacerlo).
#
# La forma en que se llama a esta función desde curve_fit es: `fit_model_wrapper(time, p1, p2, ...)`
# donde `time` es el array completo de tiempos.
# `X_t_values` es un array de la misma longitud que `time`.
# Simpler lambdify for the current model parameters, handle X_val in wrapper
func_for_current_model = lambdify(args_for_lambdify, equation_expr.subs({X_val_sym: X_val_sym}), 'numpy') # Keep X_val symbolic for now
# Now call it, and if it needs X_val, it must be part of its signature or global
# This part is tricky with sympy and curve_fit when there are interdependencies.
# The lambda should capture X_t_values correctly if subs_dict[X_val_sym] is set.
# Let's evaluate por punto para mayor robustez con X_val siendo un array
y_calculated = np.empty_like(t_data, dtype=float)
for i, ti in enumerate(t_data):
point_subs = subs_dict.copy()
point_subs[t_sym] = ti
if X_val_sym in point_subs and isinstance(point_subs[X_val_sym], np.ndarray):
# Si X_val es un array, tomar el valor correspondiente a ti
point_subs[X_val_sym] = point_subs[X_val_sym][i]
y_calculated[i] = equation_expr.subs(point_subs).evalf()
return y_calculated
else: # Modelo de Biomasa o modelo S/P que no usa X_val (raro)
func = lambdify(args_for_lambdify, equation_expr, 'numpy')
return func(t_data, *current_p_values)
p0 = np.ones(len(current_param_names)) # Estimaciones iniciales
lower_bounds, upper_bounds = bounds
# Asegurar que los límites tengan la longitud correcta
lower_bounds = np.array(lower_bounds if len(lower_bounds) == len(p0) else [-np.inf] * len(p0))
upper_bounds = np.array(upper_bounds if len(upper_bounds) == len(p0) else [np.inf] * len(p0))
# Ignorar advertencias de RuntimeWarning de operaciones inválidas (ej. división por cero durante la optimización)
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
warnings.simplefilter("ignore", UserWarning) # Sympy lambdify warnings
popt, pcov = curve_fit(fit_model_wrapper, time, data, p0=p0, bounds=(lower_bounds, upper_bounds), maxfev=100000)
self.params[model_type] = dict(zip(current_param_names, popt))
# Calcular R^2 y RMSE usando los parámetros ajustados
# Es importante volver a llamar a fit_model_wrapper para obtener y_pred con los popt,
# ya que la función interna maneja las dependencias de X(t)
y_pred = fit_model_wrapper(time, *popt)
ss_res = np.sum((data - y_pred) ** 2)
ss_tot = np.sum((data - np.mean(data)) ** 2)
if ss_tot == 0: # Evitar división por cero si los datos son constantes
self.r2[model_type] = 1.0 if ss_res < 1e-9 else 0.0
else:
self.r2[model_type] = 1 - (ss_res / ss_tot)
self.rmse[model_type] = np.sqrt(mean_squared_error(data, y_pred))
return y_pred, popt # Devolver y_pred y los parámetros optimizados