Finetuning de un LLM con Python – Tercera parte

Para lograr unas excelentes métricas no es suficiente con un tener un dataset privilegiado. Es necesario ahora adentrarnos en el mundo de las redes neuronales, la matemática, la estadística y la programación para crear ciclos de entrenamiento que modifiquen los parámetros de las redes neuronales generales y los ajusten para volverlas más especializadas en nuestro dataset.
NOFRAUD ha probado diferentes Frameworks para el finetuning de LLMs. En esta ocasión mostraremos cómo lo hicimos con Unsloth y Linux.
Vamos a ir paso por paso sobre la ejecución del entrenamiento, mostrando cómo y por qué en NOFRAUD escogimos los hyperparámetros adecuados para el finetuning de un modelo fundacional y la creación de FraudGPT.
Arquitectura y modelo fundacional
Hemos dispuesto de un servidor Linux con una GPU NVIDIA RTX 6000 Ada SFF de 48GB de memoria VRAM. En este servidor hemos instalado el ambiente de desarrollo de CUDA y hemos creado un contenedor con CONDA, donde desplegaremos el framework Unsloth y las librerías necesarias para correr el Finetuning.
Hemos elegido el modelo fundacional GPT-OSS de OpenAI de 20 Billones de parámetros para realizarle el fine tuning. Este modelo es uno de los pocos de pesos abiertos que soporta razonamiento y quantización en formato MXFP4, lo que lo hace muy rápido y eficiente.
Iniciamos con la preparación del ambiente de desarrollo en CONDA:
# wget https://repo.anaconda.com/archive/Anaconda3-2025.06-1-Linux-x86_64.sh
# bash Anaconda3-2025.06-1-Linux-x86_64.sh
# cd /root/anaconda3/
# ./conda env list
# ./conda create -n unsloth-env python=3.10
# ./conda init
# conda activate unsloth-env
Realizamos la instalación del framework Unsloth y sus dependencias:
# pip install "torch>=2.8.0" "triton>=3.4.0" numpy torchvision bitsandbytes
"transformers>=4.55.3" "unsloth_zoo[base] @ git+https://github.com/unslothai/unsloth-zoo"
"unsloth[base] @ git+https://github.com/unslothai/unsloth"
git+https://github.com/triton-lang/triton.git@05b2c186#subdirectory=python/triton_kernels
# pip install transformers==4.55.4
# pip install --no-deps trl==0.22.2
Y por último, para dejar listo el ambiente de desarrollo, compilamos llama.cpp:
# apt-get install pciutils build-essential cmake curl libcurl4-openssl-dev -y
# git clone https://github.com/ggerganov/llama.cpp
# cd llama.cpp
# cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES="89"
# cmake --build build --config Release -j 14
Formateo del dataset
Antes de cargar el dataset a Python, debemos formatearlo en la plantilla que entiende el modelo GPT-OSS. Este formato se llama Harmony y consta de etiquetas parecidas a las usadas en HTML para indicar el inicio de un comando y una cadena de texto. El script de conversión lo obviaremos porque es una tarea muy sencilla. Sin embargo, aquí está un ejemplo de cómo debe quedar formateado cada ejemplo en un archivo JSONL:
<|start|>system<|message|>
Eres un experto en la detección de comportamientos anti éticos y usas como base teórica el
modelo de Donald Cressey (presión, oportunidad y justificación). Tu tarea es analizar mensajes
que escriben los empleados en herramientas colaborativas y en caso de que encuentres en
ellos la presencia de actos éticamente cuestionables, deshonestos o malintencionados,
responderás con un SI, en caso contrario responderás con un NO.
<|end|>
<|start|>user<|message|>
Analiza por favor el siguiente mensaje:
#########
Parce pues lo voy a hacer por ser un valor tan bajo, pero se debería abrir la licitación para
todos nuevamente porque le estamos dando más tiempo a un solo proveedor después de
haber conocido las cotizaciones de los otros y pues por auditoría no está bien visto, pero voy
pues a hacerlo y en caso de ser mas barato lo legalizamos por la plataforma
#########.
Las características de este mensaje son:
Remitente: Coordinador de compras,
Destinatario: Gerente de TI,
Contexto: Comunicación de WhatsApp,
Antiguedad: 4 años,
Sexo: Masculino,
Edad: 37,
País: Colombia,
Sector: Financiero,
Nivel de poder: Puede autorizar, puede elegir proveedor
<|end|>
<|start|>assistant<|channel|>analysis<|message|>
Analicemos este mensaje por partes. Primero, me aseguraré de entender de qué se está
hablando. Segundo, buscaré la presencia de un anti valor (una conducta dañina o negativa
hacia la empresa u otras personas). Tercero, buscaré si está presente algún elemento de la
teoría de Donald Cressey y finalmente determinaré si el mensaje sirve como alerta para
detectar un acto antiético. En primer lugar, veo que el tema gira en torno a un proceso
licitatorio. Parece que el mensaje es de carácter corporativo y noto la presencia de un tono
negativo. En segundo lugar, noto la presencia de un antivalor porque veo que se está
proponiendo abrir una licitación para un solo proveedor que presuntamente estaba ya cerrada
y se justifica en que lo puede hacer porque el valor de la licitación es muy bajo, pero al mismo
tiempo se cuestiona moralmente la actuación porque dice que el deber ser es abrirla para
todos nuevamente y no para uno solo, no porque este mal visto por auditoría sino que también
porque ese proveedor podría tener ventajas sobre otros. En tercer lugar, según la teoría de
Donald Cressey, encuentro comportamientos de oportunidad, al darle a un proveedor una
ventaja sobre otros al permitirle cotizar una vez que la licitación ha sido cerrada. De acuerdo
a este análisis estructurado, considero que este mensaje sí puede ser considerado como
alerta para detectar y/o prevenir actos anti éticos. Encuentro en este mensaje lo siguiente:
Categoría: Administración de proveedores,
Probabilidad: 97%,
Riesgo: Colusión con proveedores,
Temáticas: Proveedores, licitaciones, auditoría,
Presion: Por ser descubiertos por auditoría,
Oportunidad, Contratar el proveedor preferido, mostrándole los precios de los otros proveedores
que participan en la licitación,
Justificación: No está presente explícitamente,
Relevancia: Importante para prevenir colusión,
Negatividad: Se nota la presencia de un tono negativo al reconocer que están haciendo algo
que no deberían hacer,
Honestidad: El mensaje parece ser sincero, no contiene sarcasmos ni tonos de chiste o burlas,
Explicabilidad: Se habla de unas cotizaciones de proveedores que se presentan en un tiempo
determinado donde se menciona la oportunidad de incluir a un proveedor con un precio más bajo,
donde auditoria no tiene conocimiento. De acuerdo al árbol de fraude de ACFE (Association of
Certified Fraud Examiners), este tipo de actos están clasificados como adjudicación de compras
o contratos y manipulación de licitaciones, lo cual es un conflicto de interés y por ende
corrupción. Cuando alguien dice 'por auditoría no está bien visto' lo que está queriendo decir es
que está reconociendo que lo que está haciendo no es éticamente correcto. Hay una situación
moralmente cuestionable en este proceso donde la persona dice que 'por se un valor tan bajo'
lo va a realizar, como queriendo decir que nadie se va a dar cuenta por no ser un valor alto y
cuando menciona que hay un proveedor que conoce las cotizaciones de los otros, claramente
estamos ante una situación de corrupción en el proceso de licitación o compras de la
organización. De acuerdo a la teoría de Donald Cressey (presión, oportunidad y justificación),
tener acceso a los procesos de compras y manipular las fechas de cierre de las licitaciones y
negociaciones es la oportunidad que esperan las personas para cometer fraude y obtener
beneficios de los proveedores asignados. Conocer el sistema y las fallas de control interno
para beneficiar a terceros es otra oportunidad clara que asociada con la capacidad de ejercer
control y manipular los resultados, genera la oportunidad de cometer fraude en compras y
adjudicación de contratos.
<|end|>
<|start|>assistant<|channel|>final<|message|>
SI
<|return|>
Nótese el uso de las etiquetas <|start|>, <|end|>, <|message|>, <|return|> y <|channel|>. Cada una de estas etiquetas tiene un propósito especial dentro de la plantilla y determina cómo el LLM interpretará nuestro dataset, si le estamos haciendo una pregunta, si le estamos ayudando a razonar, si le estamos dando la respuesta, el prompt o las instrucciones.
Script de finetuning
No vamos a mostrar todo el script, sin embargo sí vamos a mostrar las partes más relevantes del mismo y a dar algunas explicaciones sobre los valores elegidos para los hyper parámetros.
Se configura el modelo base gpt-oss-20b indicando que la ventana de contexto será de 4096 tokens:
max_seq_length = 4096
dtype = None
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "unsloth/gpt-oss-20b",
dtype = dtype,
max_seq_length = max_seq_length,
load_in_4bit = True,
full_finetuning = False,
)
Se carga el dataset y se indica que se quiere reservar un 20% del mismo para evaluación:
dataset = load_dataset("json", data_files="dataset.jsonl", split="train")
dataset_dict = dataset.train_test_split(test_size=0.20)
train_dataset = dataset_dict["train"]
eval_dataset = dataset_dict["test"]
Se establecen los principales parámetros para entrenamiento:
model = FastLanguageModel.get_peft_model(
model,
r = 8,
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj","up_proj","down_proj"],
lora_alpha = 32,
lora_dropout = 0.10,
bias = "none",
use_gradient_checkpointing = "unsloth",
random_state = 3407,
use_rslora = True,
loftq_config = None,
)
trainer = SFTTrainer(
model = model,
tokenizer = tokenizer,
train_dataset = train_dataset,
eval_dataset = eval_dataset,
args = SFTConfig(
per_device_train_batch_size = 1,
gradient_accumulation_steps = 16,
fp16_full_eval = True,
per_device_eval_batch_size = 1,
eval_accumulation_steps = 8,
max_grad_norm = 0.5,
eval_strategy = "epoch",
save_strategy = "epoch",
warmup_ratio = 0.10,
num_train_epochs = 2,
learning_rate = 3e-5,
logging_steps = 1,
optim = "paged_adamw_8bit",
weight_decay = 0.0,
lr_scheduler_type = "cosine",
output_dir = "outputs",
report_to = "wandb",
load_best_model_at_end = True,
metric_for_best_model = "eval_loss",
greater_is_better = False,
),
)
Se configura el entrenamiento para que solamente se haga en la respuesta final “SI” o “NO”:
trainer = train_on_responses_only(
trainer,
instruction_part = "<|start|>user<|message|>",
response_part = "<|start|>assistant<|channel|>final<|message|>",
)
Se guarda el modelo y se prepara para salida a producción:
model.save_pretrained("finetuned_adapters")
model.save_pretrained_merged("mxfp4_model", tokenizer, save_method="mxfp4")
Se convierte el modelo finetuneado en formato GGUF para cargarlo después en Ollama:
# python3.10 llama.cpp/convert_hf_to_gguf.py merged_model --outfile fraudgpt7-mxfp4.gguf
Cálculo de métricas F1, Recall y Precision
Para generar las métricas necesitamos extraer la respuesta final que entrega la inferencia del modelo. Nos interesa saber si respondió SI o NO. Por ello, la siguiente función nos ayudará a extraer la respuesta binaria:
def _normalize_si_no(text: str) -> str | None:
if text is None:
return None
t = text.strip()
t = t.upper().replace("Í", "I")
t = re.sub(r'^[\s"\'\.\,\:\;\-\_\(\)\[\]\{\}]+', '', t)
if t.startswith("SI"):
return "SI"
if t.startswith("NO"):
return "NO"
m = re.search(r'\b(SI|NO)\b', t, flags=re.IGNORECASE)
if m:
return m.group(1).upper()
return None
Extraemos de cada ejemplo del set de evaluación, la verdad absoluta con la cual será comparada la inferencia del modelo final:
def extract_ground_truth_from_example(ex) -> str:
if "text" in ex:
text = ex["text"]
pattern = r'<\|start\|>assistant<\|channel\|>final<\|message\|>([^<]+)<\|return\|>'
match = re.search(pattern, text)
if match:
response = match.group(1).strip()
gt = _normalize_si_no(response)
if gt in ("SI", "NO"):
return gt
raise ValueError("No pude encontrar la etiqueta 'SI' o 'NO' en el ejemplo.")
Extraemos de cada ejemplo del set de evaluación, el system, el mensaje del usuario y por separado la respuesta:
def build_eval_records(eval_dataset) -> list:
records = []
for ex in eval_dataset:
label = extract_ground_truth_from_example(ex)
if "text" in ex:
text = ex["text"]
messages = []
system_pattern = r'<\|start\|>system<\|message\|>([^<]+)<\|end\|>'
system_match = re.search(system_pattern, text)
if system_match:
system_content = system_match.group(1).strip()
messages.append({"role": "system", "content": system_content})
user_pattern = r'<\|start\|>user<\|message\|>([^<]+)<\|end\|>'
user_match = re.search(user_pattern, text)
if user_match:
user_content = user_match.group(1).strip()
messages.append({"role": "user", "content": user_content})
if not messages:
raise ValueError("No se pudieron extraer mensajes del prompt y system.")
records.append({"prompt_messages": messages, "label": label})
else:
raise ValueError("El ejemplo no tiene el campo 'text'.")
return records
Esta función ejecuta la inferencia y extrae de ella la respuesta (SI, NO) y el texto generado:
@torch.no_grad()
def predict_label(model, tokenizer, messages, max_new_tokens=10, debug=False, basemodel=False):
# Timeout para esta generación
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(GENERATION_TIMEOUT)
try:
prompt_text = ""
for msg in messages:
role = msg["role"]
content = msg["content"]
if not basemodel:
if role == "system":
prompt_text += f"<|start|>system<|message|>{content}<|end|>"
elif role == "user":
prompt_text += f"<|start|>user<|message|>{content}<|end|>"
prompt_text += "<|start|>assistant<|channel|>final<|message|>"
inputs = tokenizer(prompt_text, return_tensors="pt").to(model.device)
if not basemodel:
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
temperature=0.0,
top_p=1.0,
pad_token_id=tokenizer.eos_token_id,
use_cache=True,
past_key_values=None,
)
# Extraer solo la parte generada (sin el prompt)
if not basemodel:
gen_ids = outputs[0][inputs["input_ids"].shape[-1]:]
gen_text = tokenizer.decode(gen_ids, skip_special_tokens=True)
pred = _normalize_si_no(gen_text) or "NO"
if debug:
print(f"\nDEBUG - Texto completo: '{gen_text}'")
print(f"\nDEBUG - Predicción normalizada: '{pred}'")
return pred, gen_text
except TimeoutError:
print(f"Timeout alcanzado ({GENERATION_TIMEOUT}s) - Saltando esta iteración")
return "SI", "TIMEOUT"
except Exception as e:
print(f"Error en generación: {e} - Saltando esta iteración")
return "SI", "ERROR"
finally:
# Limpiar el timeout
signal.alarm(0)
Y finalmente la función principal que calcula las métricas soportándose en las funciones anteriores:
def f1(model, tokenizer, eval_dataset, limit=None, log_wandb=True, debug=False, basemodel=False):
model.eval()
records = build_eval_records(eval_dataset)
if limit is not None:
records = records[:limit]
y_true, y_pred = [], []
generations = []
timeout_count = 0
error_count = 0
for i, rec in enumerate(tqdm(records, desc="Evaluando (generación)")):
# Solo mostrar debug para las primeras 5 predicciones
show_debug = debug and i < 5
pred, gen_text = predict_label(
model, tokenizer, rec["prompt_messages"], debug=show_debug, basemodel=basemodel
)
# Contar timeouts y errores
if gen_text == "TIMEOUT":
timeout_count += 1
elif gen_text == "ERROR":
error_count += 1
y_true.append(rec["label"])
y_pred.append(pred)
generations.append(gen_text)
# Limpiar memoria cada 15 evaluaciones
if (i + 1) % 10 == 0:
torch.cuda.empty_cache()
# Debug: Analizar las predicciones antes de calcular métricas
print(f"\n=== DEBUG DE PREDICCIONES ===")
print(f"Total de muestras evaluadas: {len(y_true)}")
print(f"Distribución de Ground Truth: SI={y_true.count('SI')}, NO={y_true.count('NO')}")
print(f"Distribución de Predicciones: SI={y_pred.count('SI')}, NO={y_pred.count('NO')}")
# Contar errores por tipo
si_to_no = sum(1 for gt, pred in zip(y_true, y_pred) if gt == "SI" and pred == "NO")
no_to_si = sum(1 for gt, pred in zip(y_true, y_pred) if gt == "NO" and pred == "SI")
print(f"Errores SI→NO: {si_to_no}")
print(f"Errores NO→SI: {no_to_si}")
print(f"Casos correctos: {len(y_true) - si_to_no - no_to_si}")
print("=" * 40)
# Reporte de timeouts y errores
print(f"\nESTADÍSTICAS DE EVALUACIÓN:")
print(f" Total de muestras: {len(records)}")
print(f" Timeouts: {timeout_count} ({timeout_count/len(records)*100:.1f}%)")
print(f" Errores: {error_count} ({error_count/len(records)*100:.1f}%)")
# Métricas
acc = accuracy_score(y_true, y_pred)
# F1 binaria con "SI" como clase positiva
f1_pos = f1_score(y_true, y_pred, pos_label="SI")
precision, recall, f1_each, _ = precision_recall_fscore_support(
y_true, y_pred, labels=["NO","SI"], zero_division=0
)
f1_macro = f1_score(y_true, y_pred, average="macro")
cm = confusion_matrix(y_true, y_pred, labels=["NO","SI"])
print(f"\nAccuracy: {acc:.4f}")
print(f"F1(SI, binaria): {f1_pos:.4f}")
print(f"F1(macro): {f1_macro:.4f}")
print("\nPor clase [NO, SI]:")
print(f"- Precisión: {precision}")
print(f"- Recall: {recall}")
print(f"- F1: {f1_each}")
print("\nMatriz de confusión (rows=Y_true, cols=Y_pred) [NO, SI]:")
print(cm)
print("\nReporte de clasificación:")
print(classification_report(y_true, y_pred, labels=["NO","SI"]))
# Log en W&B
try:
if log_wandb:
import wandb
wandb.log({
"eval/accuracy": acc,
"eval/f1_si": f1_pos,
"eval/f1_macro": f1_macro,
"eval/cm_no_no": int(cm[0,0]),
"eval/cm_no_si": int(cm[0,1]),
"eval/cm_si_no": int(cm[1,0]),
"eval/cm_si_si": int(cm[1,1]),
})
except Exception as e:
print(f"[Aviso] No se pudo loggear en W&B: {e}")
# Limpieza final de memoria
torch.cuda.empty_cache()
import gc
gc.collect()
return {
"accuracy": acc,
"f1_si": f1_pos,
"f1_macro": f1_macro,
"precision_per_class": precision,
"recall_per_class": recall,
"f1_per_class": f1_each,
"confusion_matrix": cm,
"y_true": y_true,
"y_pred": y_pred,
"generations": generations,
}
Monitoreo y registro
En NOFRAUD usamos WANDB para registrar cada ciclo de entrenamiento:

Resultados finales
Qué se tiene hasta aquí? un modelo fundacional entrenado para comportarse como un experto antifraude que es capaz de detectar conductas anti éticas en entornos corporativos. Hagamos un resumen de lo que hemos hecho para llegar acá:
- Creamos un dataset de 10mil casuísticas de casos de comportamientos deshonestos reales, recolectados, documentados y curados durante 12 años por personal experto antifraude certificado como CFE por la ACFE.
- Entrenamos un modelo fundacional con este dataset y exportamos el modelo al formato GGUF, para más adelante configurarlo en Ollama a través de una API.
- Medimos la efectividad del modelo generando las métricas F1, Recall y Precision que nos dan confianza para poner el modelo en producción para nuestros clientes. Si quiere ver el resultado de las métricas lea la primera parte donde se hablan de ellas.
Lo más importante para entrenar un LLM son los datos y no solo lo más importante sino lo más laborioso y demorado de lograr.
Continúe leyendo …
Devuélvase a la segunda parte: Un Dataset privilegiado o siga leyendo la cuarta parte: API de FraudGPT
Acerca de NOFRAUD
NOFRAUD es la compañía que desarrolla el software antifraude The Fraud Explorer y apoya a personas y empresas a enfrentar y solucionar sus retos en materia de fraude interno, corrupción y abuso corporativo. NOFRAUD ha creado la base de datos conductual de actos deshonestos más grande del mundo en Español e Inglés, que sirve para que la inteligencia artificial encuentre patrones sospechosos de corrupción al interior de las organizaciones.

Mejoramos la capacidad de las organizaciones incrementando sus beneficios, arrebatándole a los perpetradores la posibilidad de afectar negativamente los ingresos a través del fraude, la corrupción, el abuso corporativo y la generación de ambientes tóxicos.
Contacte conmigo en » jrios@nofraud.la y Visítenos en » www.nofraud.la.