chronoflux / core /models.py
xcenic's picture
Upload 19 files
28927dd verified
Raw
History Blame Contribute Delete
8.6 kB
"""
models.py — Capa cognitiva (NLP zero-shot + Random Forest termodinámico).
Diferencia con el original: los modelos NO se cargan al importar el módulo
(eso obligaba a tener transformers/torch presentes solo para arrancar). Aquí se
cargan de forma PEREZOSA (lazy) la primera vez que se necesitan, vía
get_nlp_model() y get_tr_model(). La lógica de inferencia (calcular_ic_ia,
calcular_tr_y_ic_dinamico) es idéntica a app_tesis_final_V3.py.
"""
import warnings
import numpy as np
from .constants import ENFORCE_TR0_RESILIENTES
warnings.filterwarnings("ignore")
# ------------------------------------------------------------------
# Disponibilidad de librerías de IA (independientes entre sí).
# El Random Forest solo necesita sklearn; el NLP necesita transformers.
# ------------------------------------------------------------------
try:
from sklearn.ensemble import RandomForestRegressor # noqa: F401
ML_DISPONIBLE = True
except ImportError:
ML_DISPONIBLE = False
try:
from transformers import pipeline as _hf_pipeline # noqa: F401
NLP_DISPONIBLE = True
except ImportError:
NLP_DISPONIBLE = False
# Compatibilidad: "hay IA" si al menos uno de los dos modelos está disponible.
IA_DISPONIBLE = ML_DISPONIBLE or NLP_DISPONIBLE
# Singletons perezosos (se rellenan al primer uso).
_nlp_classifier = None
_nlp_cargado = False
_ml_tr_model = None
_ml_cargado = False
# Caché de predicciones del Random Forest (clave: entradas redondeadas).
_TR_CACHE: dict = {}
def get_nlp_model():
"""Carga perezosa del clasificador zero-shot. Devuelve None si no se puede."""
global _nlp_classifier, _nlp_cargado
if _nlp_cargado:
return _nlp_classifier
_nlp_cargado = True
if not NLP_DISPONIBLE:
_nlp_classifier = None
return None
try:
from transformers import pipeline
_nlp_classifier = pipeline(
"zero-shot-classification", model="Recognai/zeroshot_selectra_medium"
)
except Exception:
_nlp_classifier = None
return _nlp_classifier
def get_tr_model():
"""Carga/entrena perezosamente el Random Forest termodinámico (Tr en horas)."""
global _ml_tr_model, _ml_cargado
if _ml_cargado:
return _ml_tr_model
_ml_cargado = True
_ml_tr_model = _entrenar_modelo_termodinamico()
return _ml_tr_model
def _entrenar_modelo_termodinamico():
"""
Generador de datos sintéticos basados en física real (idéntico al original).
Entrena al Random Forest para que devuelva HORAS de secado respondiendo
correctamente a la temperatura y humedad.
"""
if not ML_DISPONIBLE:
return None
from sklearn.ensemble import RandomForestRegressor
np.random.seed(42)
X_train = []
y_train = []
# Creamos 2000 permutaciones físicas para entrenar a la IA
for _ in range(2000):
lluvia = np.random.uniform(0.1, 100.0)
temp = np.random.uniform(15.0, 45.0)
hum = np.random.uniform(30.0, 100.0)
suelo = np.random.choice([1, 2, 3])
# Fórmula física real de evaporación
evap_rate = max(0.1, (temp / 15.0) * ((100.0 - hum) / 40.0))
factor_suelo = {1: 1.5, 2: 1.0, 3: 0.5}[suelo] # 1 retiene más agua
tr_horas = (lluvia / evap_rate) * factor_suelo
X_train.append([lluvia, temp, hum, suelo])
y_train.append(min(96.0, tr_horas)) # Cap máximo de 96 horas
modelo = RandomForestRegressor(n_estimators=50, random_state=42)
modelo.fit(X_train, y_train)
return modelo
# ==============================================================================
# INFERENCIA SEMÁNTICA (Ic) — con caché por nombre y clasificación en lote.
# Los valores son idénticos a clasificar tarea por tarea; solo se evita repetir
# la inferencia para nombres iguales (clave para proyectos viales grandes).
# ==============================================================================
_IC_CACHE: dict = {}
_CATEGORIAS_IC = [
"estructuras de hormigón y acero",
"pavimento asfáltico y terminaciones",
"bases granulares y subbases",
"movimiento de tierras pesado y excavación",
]
_MAPA_IC = {_CATEGORIAS_IC[0]: 1.0, _CATEGORIAS_IC[1]: 1.5, _CATEGORIAS_IC[2]: 2.0, _CATEGORIAS_IC[3]: 3.0}
def _ic_por_palabras_clave(nombre_str: str) -> float:
"""Fallback determinista por RegEx (idéntico al original)."""
if any(w in nombre_str for w in ['acero', 'hormigon', 'hormigón', 'encofrado', 'vaciado', 'muro', 'alcantarilla', 'losa', 'zapata', 'columna', 'viga', 'platea', 'fundacion', 'fundación', 'estructura', 'paisajismo', 'limpieza', 'grama', 'terminacion', 'terminación']):
return 1.0
elif any(w in nombre_str for w in ['pintura', 'señalizacion', 'señalización']):
return 1.5
elif any(w in nombre_str for w in ['base', 'subbase', 'sub-base', 'granular', 'afirmado', 'asfalto', 'imprimacion', 'imprimación']):
return 2.0
elif any(w in nombre_str for w in ['corte', 'relleno', 'subrasante', 'tierra', 'excavacion', 'excavación']):
return 3.0
return 1.5
def precompute_ic_batch(nombres, usar_ia=True):
"""Clasifica en UN SOLO lote todos los nombres únicos y llena el caché.
Acelera drásticamente la corrida: una llamada al modelo en lugar de N. Si la IA
no está disponible no hace nada (el motor usará el fallback por palabras clave).
"""
if not usar_ia:
return
nlp = get_nlp_model()
if not nlp:
return
unicos = sorted({str(n).lower() for n in nombres if n is not None and str(n).strip()})
pendientes = [n for n in unicos if (n, True) not in _IC_CACHE]
if not pendientes:
return
try:
resultados = nlp(pendientes, _CATEGORIAS_IC)
if isinstance(resultados, dict): # el pipeline devuelve dict si es un solo texto
resultados = [resultados]
for nombre, res in zip(pendientes, resultados):
_IC_CACHE[(nombre, True)] = _MAPA_IC.get(res['labels'][0], 1.5)
except Exception:
pass
def calcular_ic_ia(nombre_tarea, usar_ia=True):
nombre_str = str(nombre_tarea).lower()
key = (nombre_str, bool(usar_ia))
cached = _IC_CACHE.get(key)
if cached is not None:
return cached
nlp_classifier = get_nlp_model() if usar_ia else None
if not usar_ia or not nlp_classifier:
val = _ic_por_palabras_clave(nombre_str)
_IC_CACHE[key] = val
return val
try:
res = nlp_classifier(nombre_str, _CATEGORIAS_IC)
val = _MAPA_IC.get(res['labels'][0], 1.5)
except Exception:
val = 1.5
_IC_CACHE[key] = val
return val
def calcular_tr_y_ic_dinamico(lluvia_mm, temp_c, humedad_pct, tipo_suelo_ic, usar_ia=True):
ml_tr_model = get_tr_model() if usar_ia else None
if not usar_ia or not ml_tr_model:
# [AUD-04] Fallback ciego (Ceguera Térmica, CFX-AUDIT-08): valores estáticos
# declarados en la tesis: 48 h arcillas, 12 h granulares, 6 h asfalto, 0 h estructuras.
if tipo_suelo_ic >= 3.0:
return 48.0, tipo_suelo_ic
elif tipo_suelo_ic >= 2.0:
return 12.0, tipo_suelo_ic
elif tipo_suelo_ic >= 1.5:
return 6.0, tipo_suelo_ic
else:
return 0.0, tipo_suelo_ic
# [AUD-05] Ec. 6.19: Δθ=0 ⟹ Tr=0 para hormigón/acero (modo estricto opcional).
if ENFORCE_TR0_RESILIENTES and tipo_suelo_ic <= 1.0:
return 0.0, tipo_suelo_ic
suelo_cat = 1 if tipo_suelo_ic >= 3.0 else (2 if tipo_suelo_ic >= 2.0 else 3)
# La IA predice HORAS reales. Cacheamos la predicción con las entradas redondeadas:
# sklearn tarda ~3.8 ms por predicción de UNA muestra, y el motor la invoca una vez
# por día lluvioso; en un proyecto grande eso son decenas de miles de llamadas
# idénticas. El redondeo (0.5 mm / 0.5 °C / 0.5 %) deja Tr prácticamente igual y
# colapsa miles de llamadas a unas pocas. Resultado: hasta cientos de veces más rápido.
key = (round(lluvia_mm * 2) / 2, round(temp_c * 2) / 2, round(humedad_pct * 2) / 2, suelo_cat)
tr_horas = _TR_CACHE.get(key)
if tr_horas is None:
tr_horas = float(ml_tr_model.predict([[key[0], key[1], key[2], suelo_cat]])[0])
_TR_CACHE[key] = tr_horas
# [AUD-07] Penalización dinámica del coeficiente — Ec. 6.22 de la tesis:
# Ic_dinámico = Q(Ic) = Ic_base + Tr/48 (constante de normalización 48 h)
tr_dias = tr_horas / 24.0
ic_dinamico = round(tipo_suelo_ic + (tr_dias * 0.5), 2)
return round(tr_horas, 1), ic_dinamico