Nota: Este es el 4to post de la serie "Entrenando mi red neuronal sin frameworks".
Si te perdiste el inicio:
Parte 1: La Idea y la Arquitectura
Parte 2: Ingeniería de datos
Parte 3: 99.9% de precisión en Test Data tras 4 horas de Vibe Coding
Introducción
La semana pasada conseguí y celebré una gran victoria personal: logré que Prize, mi red neuronal casera, clasificara intenciones de búsqueda entre 2 clases (farmacia y laboratorio dental) con una precisión de 99.9%.
Para este post tenía pensado hacer un benchmarking entre Prize y mi API usando OpenRouter, pero decidí primero intentar entrenar a Prize para que identifique cuando un usuario está pidiendo consejo médico, con el objeto de poder indicarle de manera respetuosa que busque ayuda médica especializada para su problema.
Para este propósito vamos a proceder al igual que en el post # 2 de esta serie, usando el mismo script y reemplazando la lista de stringssemillas_laboratoriopor una nueva lista con frases relevantes para nuestro propósito. Por lo demás el archivo es exactamente el mismo.
Un viejo amigo escrito en Go
Luego de tener listas nuestras 5500 frases pidiendo consejo médico, procedemos a convertirlas en embeddings usando la API de OpenRouter y un script escrito en Go que me traje de otro proyecto, aquí les dejo el script:
package main
import (
"bufio"
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
)
// Configuración
const (
ApiKey = "XXXXXXX"
BaseURL = "https://openrouter.ai/api/v1/embeddings"
ModelID = "qwen/qwen3-embedding-8b"
InputFile = "dataset_consejo_medico_sintetico.txt"
OutputFile = "embeddings_consejo_medico.txt"
BatchSize = 20
)
// Estructuras para enviar la petición JSON
type EmbeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
EncodingFormat string `json:"encoding_format"`
Dimensions int `json:"dimensions,omitempty"`
}
// Estructuras para recibir la respuesta JSON
type EmbeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func main() {
// 1. Carga el archivo con los strings
medicinas, err := leerMedicinas(InputFile)
if err != nil {
log.Fatalf("Error leyendo el archivo: %v", err)
}
fmt.Printf("Total de medicinas a procesar: %d\n", len(medicinas))
// 2. Preparar archivo CSV de salida
file, err := os.Create(OutputFile)
if err != nil {
log.Fatalf("No se pudo crear el archivo CSV: %v", err)
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// 3. Procesar por lotes
for i := 0; i < len(medicinas); i += BatchSize {
fin := i + BatchSize
if fin > len(medicinas) {
fin = len(medicinas)
}
batch := medicinas[i:fin]
fmt.Printf("Procesando lote %d (%d items)...\n", (i/BatchSize)+1, len(batch))
// Llamada a la API
embeddings, err := obtenerEmbeddings(batch)
if err != nil {
fmt.Printf("Error procesando el lote: %v\n", err)
break // Paramos si hay error grave, igual que en el script Python
}
// Guardar en CSV
for _, embedding := range embeddings {
// Creamos una fila: [Texto Original, float1, float2, ...]
row := make([]string, len(embedding)+1)
// Primera columna: Texto original
//row[0] = batch[j]
// Resto columnas: Valores del vector convertidos a string
for k, val := range embedding {
// 'f' para float, -1 precisión automática, 64 bits
row[k] = strconv.FormatFloat(val, 'f', 5, 64)
}
if err := writer.Write(row); err != nil {
log.Printf("Error escribiendo fila en CSV: %v", err)
}
}
// Forzamos escritura al disco tras cada lote por seguridad
writer.Flush()
}
// Iteramos sobre OutputFile para eliminar la coma ',' al final de cada línea
input, err := os.ReadFile(OutputFile)
if err != nil {
log.Fatalf("Error leyendo el archivo para limpieza: %v", err)
}
lines := strings.Split(string(input), "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, ",")
}
output := strings.Join(lines, "\n")
err = os.WriteFile(OutputFile, []byte(output), 0644)
if err != nil {
log.Fatalf("Error escribiendo el archivo limpio: %v", err)
}
// Notificamos al usuario el fin del proceso
fmt.Printf("Proceso finalizado. Embeddings guardados en %s\n", OutputFile)
}
// Función para leer el archivo de texto
func leerMedicinas(ruta string) ([]string, error) {
if _, err := os.Stat(ruta); os.IsNotExist(err) {
return nil, fmt.Errorf("no se encuentra el archivo %s", ruta)
}
file, err := os.Open(ruta)
if err != nil {
return nil, err
}
defer file.Close()
var lineas []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
texto := strings.TrimSpace(scanner.Text())
if texto != "" {
lineas = append(lineas, texto)
}
}
return lineas, scanner.Err()
}
// Función para hacer la petición HTTP a OpenRouter
func obtenerEmbeddings(inputs []string) ([][]float64, error) {
reqBody := EmbeddingRequest{
Model: ModelID,
Input: inputs,
EncodingFormat: "float",
Dimensions: 1024,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", BaseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+ApiKey)
// Opcional: HTTP-Referer y X-Title son requeridos a veces por OpenRouter para rankings
req.Header.Set("HTTP-Referer", "http://localhost")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code %d: %s", resp.StatusCode, string(bodyBytes))
}
var apiResponse EmbeddingResponse
if err := json.Unmarshal(bodyBytes, &apiResponse); err != nil {
return nil, fmt.Errorf("error decodificando JSON: %v", err)
}
if apiResponse.Error != nil {
return nil, fmt.Errorf("api error: %s", apiResponse.Error.Message)
}
// Extraemos solo los vectores
resultado := make([][]float64, len(apiResponse.Data))
for i, item := range apiResponse.Data {
vec := item.Embedding
// --- VERIFICACIÓN DE SEGURIDAD ---
// Si el proveedor ignoró el parámetro 'dimensions' y mandó 4096,
// cortamos el vector aquí mismo.
if len(vec) > 1024 {
vec = vec[:1024]
}
resultado[i] = vec
}
return resultado, nil
}
func obtenerEmbedding(input string) ([]float64, error) {
// Envolvemos el string único en un slice porque el struct EmbeddingRequest
// probablemente define Input como []string.
reqBody := EmbeddingRequest{
Model: ModelID,
Input: []string{input},
EncodingFormat: "float",
Dimensions: 1024,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", BaseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+ApiKey)
req.Header.Set("HTTP-Referer", "http://localhost")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code %d: %s", resp.StatusCode, string(bodyBytes))
}
var apiResponse EmbeddingResponse
if err := json.Unmarshal(bodyBytes, &apiResponse); err != nil {
return nil, fmt.Errorf("error decodificando JSON: %v", err)
}
if apiResponse.Error != nil {
return nil, fmt.Errorf("api error: %s", apiResponse.Error.Message)
}
// Verificamos que la API haya devuelto al menos un elemento
if len(apiResponse.Data) == 0 {
return nil, fmt.Errorf("la API no devolvió ningún embedding")
}
// Verificación de seguridad (opcional):
// A veces OpenRouter puede ignorar el parámetro 'dimensions' dependiendo del proveedor.
// Si te devuelve 4096, este código de respaldo lo corta.
vector := apiResponse.Data[0].Embedding
if len(vector) > 1024 {
return vector[:1024], nil
}
return vector, nil
}
Ya con nuestros embeddings listos, procedimos al entrenamiento usando los mismos scripts del post anterior, actualizándolos con cambios menores para trabajar con 3 neuronas de salida en vez de 2.
El entrenamiento
Al igual que en el primer entrenamiento, procedimos a fijar 30 épocas y ver como se comportaba Prize, aquí están los números:

En este punto, más que suerte de principiante, esto confirma la potencia de usar embeddings de calidad: al facilitar el trabajo semántico, la red aprende volando.
Volvemos a ver como Prize alcanza una precisión mayor al 99%, y ya a partir de la época 4 predice correctamente 3194 de los 3196 casos de prueba (99,94%). Sin embargo, me llamó la atención los valores altos del costo en el entrenamiento. Investigando sobre esto, entendí que estos valores altos en el costo no son un error, sino una consecuencia de la regularización L2 del script network2.py haciendo su trabajo: penalizar pesos altos para evitar que la red memorice datos, aunque la precisión sea casi perfecta. Con el objetivo de evaluar esta hipótesis sobre los valores del costo y la regularización, hice una nueva ronda de entrenamiento fijando en 0.0 el valor de lambda para "apagar" la regularización, aquí están los resultados:

Podemos observar como efectivamente, esta vez obtenemos los mismos resultados de precisión, pero esta vez con unos valores para el costo muy inferiores a la primera ronda.
Conclusión: Una IA más responsable
Con este ajuste, Prize ha evolucionado. Ya no es solo una máquina rápida; ahora tiene una capa de seguridad ética. Ahora, cuando un usuario pregunte "qué puedo tomar para el dolor", mi aplicación no le lanzará una lista de medicinas y precios. Gracias a esta nueva clase, detectaremos la intención y podremos responder: "Lo sentimos, no podemos dar consejo médico. Te sugerimos visitar a un especialista."
Para la próxima semana haremos el benchmarking entre Prize y mi API usando OpenRouter, para poder evaluar con datos la pertinencia del trabajo hecho en estas semanas.
Top comments (0)