Upload 8 files
Browse files- README.md +107 -0
- config.json +21 -0
- model.safetensors +3 -0
- modelo.py +16 -0
- predict.py +34 -0
- predict_hub.py +41 -0
- requirements.txt +6 -0
- train.py +83 -0
README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- es
|
| 4 |
+
library_name: pytorch
|
| 5 |
+
pipeline_tag: tabular-regression
|
| 6 |
+
tags:
|
| 7 |
+
- machine-learning
|
| 8 |
+
- regression
|
| 9 |
+
- linear-regression
|
| 10 |
+
- safetensors
|
| 11 |
+
- educational-example
|
| 12 |
+
license: mit
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# Regresión lineal con PyTorch: precio de una casa
|
| 16 |
+
|
| 17 |
+
## Descripción del modelo
|
| 18 |
+
|
| 19 |
+
Este modelo es un ejemplo pedagógico del Seminario de Actualización (IFTS N.º 18). Muestra cómo se entrena una regresión lineal con el ciclo explícito de PyTorch y cómo se publican los pesos en formato **safetensors**.
|
| 20 |
+
|
| 21 |
+
Predice el precio de una casa, en miles de US$, a partir de su superficie y de si tiene balcón y ascensor. Resuelve el mismo problema que el ejemplo de scikit-learn.
|
| 22 |
+
|
| 23 |
+
## Objetivo
|
| 24 |
+
|
| 25 |
+
Predecir una variable numérica continua: el precio estimado de una casa.
|
| 26 |
+
|
| 27 |
+
## Datos de entrenamiento
|
| 28 |
+
|
| 29 |
+
- **Dataset:** sintético, generado en `train.py`. Es el mismo de la Clase 10, con la misma semilla, y no son casas reales.
|
| 30 |
+
- **Cantidad de registros:** 200. Se usan 160 para entrenar y 40 para evaluar.
|
| 31 |
+
- **Variables de entrada (X):** `m2` (de 35 a 120), `balcon` (0 o 1), `ascensor` (0 o 1).
|
| 32 |
+
- **Variable objetivo (y):** `precio`, en miles de US$.
|
| 33 |
+
- **Preprocesamiento:** las entradas se normalizan restando la media y dividiendo por el desvío, ambos calculados con los datos de entrenamiento. Esos valores están en `config.json`.
|
| 34 |
+
|
| 35 |
+
## Entrenamiento
|
| 36 |
+
|
| 37 |
+
- **Tipo de aprendizaje:** supervisado.
|
| 38 |
+
- **Problema:** regresión.
|
| 39 |
+
- **Arquitectura:** una capa `torch.nn.Linear(3, 1)`, es decir, tres pesos y un bias. Está definida en `modelo.py`.
|
| 40 |
+
- **Función de pérdida:** error cuadrático medio (`MSELoss`).
|
| 41 |
+
- **Optimizador:** descenso por gradiente (`SGD`), con tasa de aprendizaje 0,1.
|
| 42 |
+
- **Épocas:** 500. La pérdida se estabiliza antes de la época 100.
|
| 43 |
+
- **Biblioteca:** PyTorch.
|
| 44 |
+
|
| 45 |
+
**Parámetros aprendidos.** Son los que guarda `model.safetensors`:
|
| 46 |
+
|
| 47 |
+
| Parámetro | Valor |
|
| 48 |
+
|---|---|
|
| 49 |
+
| `lineal.weight` | [35,157 · 4,351 · 5,992] |
|
| 50 |
+
| `lineal.bias` | 147,769 |
|
| 51 |
+
|
| 52 |
+
**Por qué los pesos no se parecen a los de scikit-learn.** Están en la escala de las entradas **normalizadas**. Si se divide cada peso por el desvío de su variable, se vuelve a la escala original. Por ejemplo, 35,157 / 23,398 ≈ 1,503 para `m2`, que es el mismo peso que encontró scikit-learn.
|
| 53 |
+
|
| 54 |
+
## Evaluación
|
| 55 |
+
|
| 56 |
+
Se evaluó sobre 40 casas que el modelo no vio durante el entrenamiento.
|
| 57 |
+
|
| 58 |
+
| Métrica | Resultado |
|
| 59 |
+
|---|---|
|
| 60 |
+
| MAE | 3,08 miles de US$ |
|
| 61 |
+
| R² | 0,989 |
|
| 62 |
+
|
| 63 |
+
## Archivos
|
| 64 |
+
|
| 65 |
+
| Archivo | Qué es |
|
| 66 |
+
|---|---|
|
| 67 |
+
| `model.safetensors` | **solo los pesos** (`state_dict`) |
|
| 68 |
+
| `config.json` | las entradas, la salida y la normalización que hay que aplicar antes de predecir |
|
| 69 |
+
| `modelo.py` | la clase del modelo: PyTorch no la guarda en el archivo de pesos |
|
| 70 |
+
| `train.py` | el código que lo entrenó |
|
| 71 |
+
| `predict.py` | cómo cargarlo y usarlo |
|
| 72 |
+
| `requirements.txt` | las dependencias |
|
| 73 |
+
| `README.md` | esta Model Card |
|
| 74 |
+
|
| 75 |
+
## Cómo utilizar el modelo
|
| 76 |
+
|
| 77 |
+
```python
|
| 78 |
+
import json
|
| 79 |
+
import numpy as np
|
| 80 |
+
import torch
|
| 81 |
+
from safetensors.torch import load_file
|
| 82 |
+
from modelo import RegresionLineal
|
| 83 |
+
|
| 84 |
+
config = json.load(open("config.json"))
|
| 85 |
+
media = np.array(config["normalizacion"]["media"], dtype="float32")
|
| 86 |
+
desvio = np.array(config["normalizacion"]["desvio"], dtype="float32")
|
| 87 |
+
|
| 88 |
+
modelo = RegresionLineal(n_entradas=3) # 1. el modelo vacío
|
| 89 |
+
modelo.load_state_dict(load_file("model.safetensors")) # 2. se le cargan los pesos
|
| 90 |
+
modelo.eval()
|
| 91 |
+
|
| 92 |
+
casa = np.array([[80, 1, 0]], dtype="float32")
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
print(modelo(torch.tensor((casa - media) / desvio))) # tensor([[148.5]])
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Limitaciones
|
| 98 |
+
|
| 99 |
+
- Tiene fines exclusivamente educativos. Los datos son sintéticos y no representan precios reales.
|
| 100 |
+
- Solo conoce casas de 35 a 120 m².
|
| 101 |
+
- **Si se olvida la normalización, las predicciones dan cualquier cosa.** Los pesos solos no alcanzan: hace falta `config.json`.
|
| 102 |
+
- Para cargar los pesos hace falta la misma arquitectura (`modelo.py`). Si cambia la clase, `load_state_dict` falla.
|
| 103 |
+
- A diferencia de pickle o joblib, **safetensors no ejecuta código al cargarse**: solo contiene números.
|
| 104 |
+
|
| 105 |
+
## Autoría
|
| 106 |
+
|
| 107 |
+
Modelo desarrollado con fines educativos para el IFTS N.º 18. Docente: Cynthia M. Villagra.
|
config.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arquitectura": "RegresionLineal (torch.nn.Linear)",
|
| 3 |
+
"entradas": [
|
| 4 |
+
"m2",
|
| 5 |
+
"balcon",
|
| 6 |
+
"ascensor"
|
| 7 |
+
],
|
| 8 |
+
"salida": "precio (miles de US$)",
|
| 9 |
+
"normalizacion": {
|
| 10 |
+
"media": [
|
| 11 |
+
78.51875305175781,
|
| 12 |
+
0.46875,
|
| 13 |
+
0.512499988079071
|
| 14 |
+
],
|
| 15 |
+
"desvio": [
|
| 16 |
+
23.397640228271484,
|
| 17 |
+
0.4990224838256836,
|
| 18 |
+
0.4998437762260437
|
| 19 |
+
]
|
| 20 |
+
}
|
| 21 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4f87d90c7f81d67c5ce4da4e585bad567d702ff36ebffe9e3e8747fb0f9e93ea
|
| 3 |
+
size 160
|
modelo.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arquitectura del modelo. La usan train.py y predict.py.
|
| 2 |
+
|
| 3 |
+
PyTorch no guarda la clase dentro del archivo de pesos: para cargar model.safetensors
|
| 4 |
+
hay que volver a crear el mismo modelo vacío y después cargarle los pesos.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class RegresionLineal(torch.nn.Module):
|
| 11 |
+
def __init__(self, n_entradas=3):
|
| 12 |
+
super().__init__()
|
| 13 |
+
self.lineal = torch.nn.Linear(n_entradas, 1) # pesos (w) y bias (b)
|
| 14 |
+
|
| 15 |
+
def forward(self, x):
|
| 16 |
+
return self.lineal(x)
|
predict.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Carga model.safetensors + config.json y predice el precio de casas nuevas.
|
| 2 |
+
|
| 3 |
+
Uso:
|
| 4 |
+
python predict.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from safetensors.torch import load_file
|
| 13 |
+
|
| 14 |
+
from modelo import RegresionLineal
|
| 15 |
+
|
| 16 |
+
CARPETA = Path(__file__).parent
|
| 17 |
+
|
| 18 |
+
# 1. Leer la configuración (entradas y normalización)
|
| 19 |
+
config = json.loads((CARPETA / "config.json").read_text(encoding="utf-8"))
|
| 20 |
+
media = np.array(config["normalizacion"]["media"], dtype="float32")
|
| 21 |
+
desvio = np.array(config["normalizacion"]["desvio"], dtype="float32")
|
| 22 |
+
|
| 23 |
+
# 2. Crear el modelo vacío y cargarle los pesos entrenados
|
| 24 |
+
modelo = RegresionLineal(n_entradas=len(config["entradas"]))
|
| 25 |
+
modelo.load_state_dict(load_file(CARPETA / "model.safetensors"))
|
| 26 |
+
modelo.eval()
|
| 27 |
+
|
| 28 |
+
# 3. Predecir: las entradas se normalizan igual que en el entrenamiento
|
| 29 |
+
casas_nuevas = np.array([[50, 0, 0], [80, 1, 0], [110, 1, 1]], dtype="float32")
|
| 30 |
+
with torch.no_grad():
|
| 31 |
+
precios = modelo(torch.tensor((casas_nuevas - media) / desvio)).numpy().ravel()
|
| 32 |
+
|
| 33 |
+
for casa, precio in zip(casas_nuevas, precios):
|
| 34 |
+
print(f"{dict(zip(config['entradas'], casa.astype(int).tolist()))} -> {precio:.1f} miles de US$")
|
predict_hub.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Descarga el modelo desde Hugging Face Hub y predice EN ESTA PC (o en el servidor donde corra).
|
| 2 |
+
|
| 3 |
+
En PyTorch del Hub se bajan los pesos y la configuración, pero la arquitectura
|
| 4 |
+
(la clase RegresionLineal) tiene que estar en tu código: por eso se importa modelo.py.
|
| 5 |
+
|
| 6 |
+
Uso:
|
| 7 |
+
python predict_hub.py tu-usuario/regresion-casas-pytorch
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from huggingface_hub import hf_hub_download
|
| 16 |
+
from safetensors.torch import load_file
|
| 17 |
+
|
| 18 |
+
from modelo import RegresionLineal
|
| 19 |
+
|
| 20 |
+
REPO_ID = sys.argv[1] if len(sys.argv) > 1 else "TU-USUARIO/regresion-casas-pytorch"
|
| 21 |
+
|
| 22 |
+
# 1. Bajar los dos archivos que hacen falta
|
| 23 |
+
ruta_pesos = hf_hub_download(repo_id=REPO_ID, filename="model.safetensors")
|
| 24 |
+
ruta_config = hf_hub_download(repo_id=REPO_ID, filename="config.json")
|
| 25 |
+
|
| 26 |
+
# 2. Armar el modelo vacío, cargarle los pesos y leer la normalización
|
| 27 |
+
with open(ruta_config, encoding="utf-8") as f:
|
| 28 |
+
config = json.load(f)
|
| 29 |
+
media = np.array(config["normalizacion"]["media"], dtype="float32")
|
| 30 |
+
desvio = np.array(config["normalizacion"]["desvio"], dtype="float32")
|
| 31 |
+
|
| 32 |
+
modelo = RegresionLineal(n_entradas=len(config["entradas"]))
|
| 33 |
+
modelo.load_state_dict(load_file(ruta_pesos))
|
| 34 |
+
modelo.eval()
|
| 35 |
+
|
| 36 |
+
# 3. Predecir
|
| 37 |
+
casas_nuevas = np.array([[50, 0, 0], [80, 1, 0], [110, 1, 1]], dtype="float32")
|
| 38 |
+
with torch.no_grad():
|
| 39 |
+
precios = modelo(torch.tensor((casas_nuevas - media) / desvio)).numpy().ravel()
|
| 40 |
+
for casa, precio in zip(casas_nuevas, precios):
|
| 41 |
+
print(f"{dict(zip(config['entradas'], casa.astype(int).tolist()))} -> {precio:.1f} miles de US$")
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.1
|
| 2 |
+
safetensors
|
| 3 |
+
numpy
|
| 4 |
+
pandas
|
| 5 |
+
scikit-learn # solo lo usa train.py, para separar train/test y medir
|
| 6 |
+
huggingface_hub # para descargar el modelo desde el Hub o publicarlo
|
train.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entrena una regresión lineal con PyTorch y la exporta a model.safetensors + config.json.
|
| 2 |
+
|
| 3 |
+
Mismo problema que el ejemplo 01: precio de una casa a partir de m², balcón y ascensor.
|
| 4 |
+
|
| 5 |
+
Diferencia importante con scikit-learn: PyTorch guarda solo los pesos (state_dict).
|
| 6 |
+
La clase del modelo y la normalización de las entradas no viajan en el archivo de pesos,
|
| 7 |
+
así que la normalización se guarda aparte, en config.json.
|
| 8 |
+
|
| 9 |
+
Uso:
|
| 10 |
+
python train.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import torch
|
| 19 |
+
from safetensors.torch import save_file
|
| 20 |
+
from sklearn.metrics import mean_absolute_error, r2_score
|
| 21 |
+
from sklearn.model_selection import train_test_split
|
| 22 |
+
|
| 23 |
+
from modelo import RegresionLineal
|
| 24 |
+
|
| 25 |
+
CARPETA = Path(__file__).parent
|
| 26 |
+
ENTRADAS = ["m2", "balcon", "ascensor"]
|
| 27 |
+
torch.manual_seed(42)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def generar_casas(n=200, semilla=42):
|
| 31 |
+
"""Dataset sintético de la Clase 10 (misma semilla, mismos datos)."""
|
| 32 |
+
rng = np.random.default_rng(semilla)
|
| 33 |
+
m2 = rng.integers(35, 121, n)
|
| 34 |
+
balcon = rng.integers(0, 2, n)
|
| 35 |
+
ascensor = rng.integers(0, 2, n)
|
| 36 |
+
precio = 1.5 * m2 + 8 * balcon + 12 * ascensor + 20 + rng.normal(0, 4, n)
|
| 37 |
+
return pd.DataFrame({"m2": m2, "balcon": balcon, "ascensor": ascensor, "precio": precio.round(1)})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# 1. Datos
|
| 41 |
+
casas = generar_casas()
|
| 42 |
+
X = casas[ENTRADAS].to_numpy(dtype="float32")
|
| 43 |
+
y = casas["precio"].to_numpy(dtype="float32").reshape(-1, 1)
|
| 44 |
+
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
|
| 45 |
+
|
| 46 |
+
# Normalización: se calcula SOLO con los datos de entrenamiento y se guarda para predecir
|
| 47 |
+
media = X_train.mean(axis=0)
|
| 48 |
+
desvio = X_train.std(axis=0)
|
| 49 |
+
X_train_t = torch.tensor((X_train - media) / desvio)
|
| 50 |
+
y_train_t = torch.tensor(y_train)
|
| 51 |
+
|
| 52 |
+
# 2. Modelo, función de pérdida y optimizador
|
| 53 |
+
modelo = RegresionLineal(n_entradas=len(ENTRADAS))
|
| 54 |
+
perdida_fn = torch.nn.MSELoss()
|
| 55 |
+
optimizador = torch.optim.SGD(modelo.parameters(), lr=0.1)
|
| 56 |
+
|
| 57 |
+
# 3. El ciclo de entrenamiento
|
| 58 |
+
for epoca in range(500):
|
| 59 |
+
prediccion = modelo(X_train_t) # el modelo predice
|
| 60 |
+
perdida = perdida_fn(prediccion, y_train_t) # la pérdida mide el error
|
| 61 |
+
optimizador.zero_grad()
|
| 62 |
+
perdida.backward() # se calculan los gradientes
|
| 63 |
+
optimizador.step() # el optimizador corrige pesos y bias
|
| 64 |
+
if epoca % 100 == 0:
|
| 65 |
+
print(f"época {epoca:3d} · pérdida (MSE) {perdida.item():.2f}")
|
| 66 |
+
|
| 67 |
+
# 4. Evaluación con datos que el modelo no vio
|
| 68 |
+
modelo.eval()
|
| 69 |
+
with torch.no_grad():
|
| 70 |
+
pred_test = modelo(torch.tensor((X_test - media) / desvio)).numpy()
|
| 71 |
+
print(f"MAE en test: {mean_absolute_error(y_test, pred_test):.2f} miles de US$")
|
| 72 |
+
print(f"R² en test: {r2_score(y_test, pred_test):.3f}")
|
| 73 |
+
|
| 74 |
+
# 5. Exportación: pesos en safetensors, todo lo demás en config.json
|
| 75 |
+
save_file(modelo.state_dict(), CARPETA / "model.safetensors")
|
| 76 |
+
config = {
|
| 77 |
+
"arquitectura": "RegresionLineal (torch.nn.Linear)",
|
| 78 |
+
"entradas": ENTRADAS,
|
| 79 |
+
"salida": "precio (miles de US$)",
|
| 80 |
+
"normalizacion": {"media": media.tolist(), "desvio": desvio.tolist()},
|
| 81 |
+
}
|
| 82 |
+
(CARPETA / "config.json").write_text(json.dumps(config, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 83 |
+
print("Modelo guardado en model.safetensors + config.json")
|