Fine-tuning — Módulo 3: chat templates, tokens especiales y loss masking
Tercer módulo del Curso de Fine-tuning práctico y el más técnico del bloque: cómo se convierte un JSONL en la secuencia de tokens que el modelo realmente ve. Chat templates de ChatML, Llama 3 y Qwen, apply_chat_template, el BOS duplicado, loss masking para entrenar solo sobre la respuesta, truncación, packing y cómo verificar todo decodificando un batch antes de entrenar.
☰ Índice del contenido
- La tesis del módulo
- De JSON a tokens: las tres capas
- Chat templates: cada familia tiene el suyo
- apply_chat_template: la única forma correcta
- Tokens especiales y el BOS duplicado
- Loss masking: el corazón del módulo
- ¿Cuánto importa en la práctica?
- En código: assistant_only_loss
- La verificación que no te puedes saltar
- Truncación: dónde se corta y por qué importa
- Packing: eficiencia con letra chica
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 2 terminaste con un JSONL limpio, curado, balanceado y validado. Es un archivo hermoso y el modelo no lo va a leer nunca. Lo que el modelo ve es una secuencia plana de números enteros, y entre tu JSON y esos números hay una capa de traducción — la plantilla de chat, los tokens especiales y la máscara de pérdida — donde ocurre la mayoría de los fine-tunes que “corren perfecto y no aprenden nada”. El síntoma es siempre el mismo: el entrenamiento termina sin un solo error, la curva de pérdida baja bonito, guardas el adaptador, lo cargas, le preguntas algo y el modelo responde peor que antes o repite el prompt del usuario. Este es el módulo que evita eso.
La tesis del módulo
El entrenamiento no falla con un error: falla en silencio. Un chat template equivocado, un token de fin duplicado o una pérdida calculada sobre el prompt completo no rompen nada — producen un entrenamiento que corre, converge y aprende lo que no era. La única defensa es decodificar un batch real y leer con tus ojos qué está viendo el modelo y sobre qué tokens se está calculando el gradiente.
De JSON a tokens: las tres capas
LO QUE ESCRIBISTE Y LO QUE EL MODELO VE
┌───────────────────────────────────────────┐
│ 1. JSONL {"messages":[{"role":"user"... │
└────────────────────┬──────────────────────┘
│ apply_chat_template
▼
┌───────────────────────────────────────────┐
│ 2. TEXTO <|im_start|>user\nHola... │
│ <|im_end|>\n<|im_start|>... │
└────────────────────┬──────────────────────┘
│ tokenizer
▼
┌───────────────────────────────────────────┐
│ 3. IDS [151644, 872, 198, 39814, ...] │
│ LABELS [ -100, -100, -100, 39814...]│
└────────────────────┬──────────────────────┘
│
▼
gradiente
El bug vive en las capas 2 y 3, y ninguna lanza error.
Las tres capas se pueden inspeccionar y hay que inspeccionarlas. La capa 2 se lee como texto; la capa 3 se lee comparando input_ids con labels. Diez minutos ahí ahorran un día de entrenamiento perdido.
Chat templates: cada familia tiene el suyo
Un chat template (glosar: la plantilla que convierte una lista de mensajes con roles en el string exacto de texto plano que ese modelo específico vio durante su instruction tuning, con sus tokens especiales de apertura y cierre de turno) no es un detalle cosmético. El modelo aprendió durante su entrenamiento que después de cierta secuencia exacta de tokens le toca hablar a él. Si usas otra, estás pidiéndole que generalice a un formato que nunca vio.
Tres familias que vas a encontrar en la práctica:
ChatML (Qwen, y muchos modelos derivados). Usa <|im_start|> y <|im_end|> — im por instant message:
<|im_start|>system
Eres un asistente conciso.<|im_end|>
<|im_start|>user
¿Cuál es la capital de Chile?<|im_end|>
<|im_start|>assistant
Santiago.<|im_end|>
Llama 3. Usa cabeceras de rol con <|start_header_id|> y cierra cada turno con <|eot_id|> (end of turn):
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
Eres un asistente conciso.<|eot_id|><|start_header_id|>user<|end_header_id|>
¿Cuál es la capital de Chile?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
Santiago.<|eot_id|>
Mistral / Mixtral (formato clásico). Usa [INST] y [/INST] sin token de rol para el asistente:
<s>[INST] ¿Cuál es la capital de Chile? [/INST] Santiago.</s>
Tres cosas que saltan a la vista comparándolos: los tokens especiales son distintos, los saltos de línea son distintos (Llama 3 mete dos después de la cabecera, y eso importa), y el manejo del rol system es distinto — Mistral clásico ni siquiera tiene uno.
Ojo: el template correcto no lo eliges tú, viene con el modelo. Está en el archivo
tokenizer_config.jsondel repositorio en Hugging Face, en el campochat_template, escrito en sintaxis Jinja. Nunca lo armes a mano por más simple que se vea: un salto de línea de diferencia es un token de diferencia, y el modelo lo nota.
apply_chat_template: la única forma correcta
"""Aplicar el chat template correcto. Esta es la única forma segura.
Regla: NUNCA concatenes strings a mano para armar la conversación.
El template vive en el tokenizer del modelo y lo aplica él.
"""
from transformers import AutoTokenizer
MODELO = "Qwen/Qwen2.5-7B-Instruct"
tok = AutoTokenizer.from_pretrained(MODELO)
mensajes = [
{"role": "system", "content": "Eres un asistente conciso."},
{"role": "user", "content": "¿Cuál es la capital de Chile?"},
{"role": "assistant", "content": "Santiago."},
]
# --- Para ENTRENAR: la conversación COMPLETA, con la respuesta,
# y sin prompt de generación (el turno ya está cerrado).
texto_entrenamiento = tok.apply_chat_template(
mensajes,
tokenize=False, # queremos ver el string, no los ids
add_generation_prompt=False, # False en entrenamiento: crítico
)
print(repr(texto_entrenamiento))
# --- Para INFERIR: solo hasta el user, y SÍ el prompt de generación,
# que abre el turno del assistant para que el modelo lo continúe.
texto_inferencia = tok.apply_chat_template(
mensajes[:-1], # sin la respuesta del assistant
tokenize=False,
add_generation_prompt=True, # True en inferencia: crítico
)
print(repr(texto_inferencia))
El parámetro add_generation_prompt es la fuente de un bug clásico y sutil. En inferencia debe ser True: agrega la apertura del turno del asistente (<|im_start|>assistant\n en ChatML) para que el modelo sepa que le toca. En entrenamiento debe ser False, porque la respuesta del asistente ya viene incluida en los mensajes y el template ya abre y cierra ese turno solo. Ponerlo en True durante el entrenamiento agrega una apertura de turno vacía al final, y el modelo aprende a producir un turno vacío después de responder.
Cuidado: algunos modelos base (no instruct) no traen
chat_templateen su tokenizer. Si aplicasapply_chat_templatesobre uno de ellos, las versiones antiguas detransformerscaían silenciosamente a una plantilla por defecto; las modernas lanzan un error. Verifica siempre contok.chat_template is not Noneantes de asumir. Si vas a afinar un modelo base sin plantilla, tienes que definir una y usarla consistentemente en entrenamiento y en inferencia.
Tokens especiales y el BOS duplicado
Dos tokens que hay que tener claros:
- BOS (glosar: Beginning Of Sequence; el token que marca el inicio de la secuencia — en Llama es
<|begin_of_text|>, en muchos otros es<s>). - EOS (glosar: End Of Sequence; el token que marca el fin — es la señal con la que el modelo detiene la generación en inferencia).
El BOS duplicado es el bug de formato más frecuente y funciona así: apply_chat_template ya incluye el BOS en el string que devuelve. Si después tokenizas ese string con tokenizer(texto) usando el default add_special_tokens=True, el tokenizer agrega otro BOS al principio. El modelo entrena con dos BOS al inicio de cada secuencia, en inferencia recibe uno solo, y la distribución de entrada es distinta a la de entrenamiento.
"""El BOS duplicado: cómo detectarlo en treinta segundos."""
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
mensajes = [{"role": "user", "content": "hola"}]
texto = tok.apply_chat_template(mensajes, tokenize=False)
# MAL: el template ya trajo BOS y el tokenizer agrega otro.
ids_mal = tok(texto)["input_ids"]
# BIEN: el string ya viene con sus tokens especiales.
ids_bien = tok(texto, add_special_tokens=False)["input_ids"]
print("bos_token_id:", tok.bos_token_id)
print("mal :", ids_mal[:4], "->", tok.convert_ids_to_tokens(ids_mal[:4]))
print("bien:", ids_bien[:4], "->", tok.convert_ids_to_tokens(ids_bien[:4]))
# mal : [128000, 128000, 128006, 882] <- BOS repetido
# bien: [128000, 128006, 882, 128007]
# Chequeo defensivo que vale la pena dejar en el código:
assert ids_bien[:2] != [tok.bos_token_id, tok.bos_token_id], "BOS duplicado"
La regla corta: si el texto salió de apply_chat_template, tokenízalo con add_special_tokens=False. Si en cambio le pasas tokenize=True a apply_chat_template, él se encarga y no hay problema.
El otro lado del mismo tema es el EOS faltante. Si tu secuencia de entrenamiento no termina con el token de fin de turno correcto, el modelo nunca aprende a parar. En producción el síntoma es inconfundible: responde bien y después sigue escribiendo, se inventa un turno del usuario, se responde solo, y sigue hasta agotar max_new_tokens. Los templates modernos cierran el turno solos, pero si armaste el formato a mano — o si el dataset trae respuestas con espacios en blanco al final que el template no limpia — revísalo.
| Síntoma en inferencia | Causa probable en el formato |
|---|---|
| El modelo no para de escribir | Falta EOS / EOT al final de la respuesta |
| Se inventa el turno del usuario | Falta EOS y entrenó sobre el prompt |
| Responde con un turno vacío al final | add_generation_prompt=True al entrenar |
| Repite el prompt antes de responder | Sin loss masking: aprendió a generar el prompt |
| Ignora el system prompt | Entrenó sin system, o con uno distinto |
| Respuestas degeneradas o incoherentes | Chat template de otra familia |
| Cortes a mitad de respuesta | Truncación por la derecha en el dataset |
Loss masking: el corazón del módulo
Aquí está lo que más rinde de este curso completo.
Un modelo de lenguaje se entrena prediciendo el siguiente token: para cada posición, el modelo produce una distribución sobre el vocabulario, se compara con el token que efectivamente venía, y de esa comparación sale la pérdida (loss, glosar: el número que mide qué tan equivocada estuvo la predicción; el entrenamiento ajusta los pesos para bajarlo). Por defecto eso se hace sobre todas las posiciones de la secuencia.
Y ahí está el problema: en tu secuencia de entrenamiento, buena parte de los tokens son el system prompt y la pregunta del usuario. Si calculas la pérdida sobre todo, le estás enseñando al modelo a generar preguntas de usuario, que es exactamente lo que no quieres. El loss masking (glosar: “enmascarar la pérdida”; marcar las posiciones que no deben contribuir al gradiente, típicamente todo lo que no sea la respuesta del asistente) resuelve eso.
El mecanismo concreto en PyTorch y Hugging Face: la función de pérdida de clasificación (CrossEntropyLoss) tiene un parámetro ignore_index cuyo valor por defecto es -100. Cualquier posición del tensor labels que valga -100 se salta al calcular la pérdida. Enmascarar es, literalmente, poner -100 en las etiquetas del prompt.
LOSS MASKING SOBRE LA SECUENCIA REAL (ChatML)
tokens <|im_start|> user \n ¿capital? <|im_end|> \n
labels -100 -100 -100 -100 -100 -100
─────── PROMPT: sin gradiente ──────
tokens <|im_start|> assistant \n
labels -100 -100 -100
─── apertura: sin gradiente ───
tokens Santiago . <|im_end|>
labels Santiago . <|im_end|>
─── RESPUESTA: aquí sí hay gradiente ───
Solo los tokens de la respuesta empujan los pesos.
El <|im_end|> final SÍ se entrena: así aprende a parar.
Fíjate en el último detalle del diagrama, que se pasa por alto seguido: el token de fin de turno tiene que estar dentro de la máscara entrenable. Si lo excluyes junto con el resto del andamiaje, el modelo nunca recibe gradiente sobre “aquí se termina” y en producción no para de escribir.
¿Cuánto importa en la práctica?
Depende de una sola razón: el ratio entre tokens de respuesta y tokens totales, el mismo ratio_respuesta que calculamos en el script de validación del módulo 2.
- Si tus respuestas son el 60% de la secuencia (conversación equilibrada), entrenar sin máscara degrada, pero el modelo igual aprende algo porque la mayoría del gradiente viene de la respuesta.
- Si tus respuestas son el 5% de la secuencia (system prompt largo + documento de contexto + una etiqueta de una palabra), entrenar sin máscara significa que el 95% del gradiente está enseñándole al modelo a generar system prompts y documentos. El fine-tune es casi puro ruido para tu objetivo.
Ese segundo caso es justamente el más común en producción — clasificación, extracción, respuestas cortas sobre contexto largo — y es la razón de que la falta de loss masking sea una de las causas más frecuentes de fine-tunes que no aprenden.
Ojo: hay un caso legítimo donde no enmascaras: cuando quieres que el modelo aprenda también la distribución de las entradas, por ejemplo en continued pretraining o cuando entrenas un modelo para completar documentos. Pero si tu objetivo es “responde así a esto”, enmascara. Si dudas, enmascara.
En código: assistant_only_loss
TRL resuelve el loss masking con dos flags de SFTConfig, según el formato del dataset: completion_only_loss (activo por defecto para datasets prompt/completion) y assistant_only_loss (para datasets conversacionales con messages). Hasta TRL 0.19 esto se hacía con un collator aparte (glosar: la función que toma varios ejemplos sueltos y arma el batch — es el punto donde se aplica el padding y donde se construyen las etiquetas), DataCollatorForCompletionOnlyLM, al que le indicabas el string que marca el inicio de la respuesta. Ese collator fue eliminado en TRL 0.20 (mediados de 2025) y todavía aparece en muchos tutoriales: si lo ves, es código viejo.
"""SFT con loss masking explícito usando TRL (>= 0.20).
La pieza clave es `assistant_only_loss=True`: TRL pone -100 en todo lo
que no sea un turno del assistant, de modo que la pérdida se calcule
solo sobre las respuestas — incluidos los <|im_end|> que las cierran.
"""
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from trl import SFTConfig, SFTTrainer
MODELO = "Qwen/Qwen2.5-7B-Instruct"
tok = AutoTokenizer.from_pretrained(MODELO)
modelo = AutoModelForCausalLM.from_pretrained(MODELO, device_map="auto")
# Formato conversacional: TRL aplica el chat template del tokenizer por
# su cuenta (con add_generation_prompt=False, como corresponde al entrenar).
datos = load_dataset("json", data_files="data/train.jsonl")["train"]
entrenador = SFTTrainer(
model=modelo,
train_dataset=datos,
processing_class=tok,
args=SFTConfig(
output_dir="./salida",
max_length=2048, # ver la sección de truncación
assistant_only_loss=True, # aquí se activa el loss masking
packing=False, # empieza sin packing (ver abajo)
num_train_epochs=2,
per_device_train_batch_size=2,
learning_rate=2e-4,
),
)
entrenador.train()
Dos advertencias sobre ese bloque, y las dos han costado entrenamientos completos:
Cuidado:
assistant_only_loss=Trueexige que el chat template marque dónde empieza y termina cada turno del assistant con{% generation %}/{% endgeneration %}. TRL parcha automáticamente el template de las familias conocidas (Qwen3, entre otras); para el resto, si el template no trae esos marcadores, la máscara queda vacía y no hay ningún token entrenable. El camino robusto en ese caso es convertir el dataset al formatoprompt/completion, dondecompletion_only_lossfunciona sin depender del template.
Cuidado: en el collator histórico, el
response_templatese comparaba a nivel de tokens, y un espacio de más lo dejaba sin encontrar: el ejemplo entero quedaba enmascarado y la pérdida no se movía. Conassistant_only_lossese bug desaparece, pero la verificación de la sección siguiente sigue siendo obligatoria: si “lo que entrena” sale vacío o incluye el prompt, algo del template no calzó.
Para conversaciones de varios turnos no hay que configurar nada más: assistant_only_loss enmascara todos los turnos del usuario y entrena todas las respuestas del assistant, de modo que en un diálogo de cinco turnos se entrenan las cinco respuestas. Con el collator histórico eso exigía además un instruction_template; sin él, todo lo posterior al primer marcador se entrenaba, turnos del usuario incluidos.
La verificación que no te puedes saltar
Todo lo anterior se comprueba en un minuto. Este es el bloque que hay que correr antes de cada train():
"""Inspección de un ejemplo real ANTES de entrenar.
Responde tres preguntas con evidencia, no con fe:
1. ¿El texto que ve el modelo tiene el formato correcto?
2. ¿Sobre qué tokens se está calculando la pérdida?
3. ¿Hay algún ejemplo completamente enmascarado?
TRL deja el dataset tokenizado en `entrenador.train_dataset`, con la
columna `labels` ya construida (-100 donde no hay gradiente).
"""
import torch
ejemplo = entrenador.train_dataset[0]
ids = torch.tensor(ejemplo["input_ids"])
labels = torch.tensor(ejemplo["labels"])
# 1) La secuencia completa, tal cual la ve el modelo.
print("=== SECUENCIA COMPLETA ===")
print(tok.decode(ids, skip_special_tokens=False))
# 2) Solo lo entrenable: los labels distintos de -100.
entrenables = ids[labels != -100]
print("\n=== SOLO LO QUE ENTRENA (debe ser la respuesta) ===")
print(tok.decode(entrenables, skip_special_tokens=False))
# 3) Proporción y sanidad. Un 0% significa que la máscara quedó vacía:
# el template no trae los marcadores y el entrenamiento sería inútil.
n_entrenables = int((labels != -100).sum())
print(f"\nentrenables: {n_entrenables}/{len(ids)} "
f"({n_entrenables / len(ids):.1%})")
assert n_entrenables > 0, "Ningún token entrenable: revisa el chat template"
# 4) Ningún ejemplo puede quedar 100% enmascarado (muestra los primeros 200).
for i in range(min(len(entrenador.train_dataset), 200)):
fila = entrenador.train_dataset[i]["labels"]
assert any(x != -100 for x in fila), f"Ejemplo {i} completamente enmascarado"
# 5) ¿La respuesta termina con el token de fin de turno?
# Si no, el modelo no aprende a parar.
print("\núltimos tokens entrenables:",
tok.convert_ids_to_tokens(entrenables[-3:].tolist()))
El punto 2 es el que se lee con los ojos y el que descubre casi todo. Si en “SOLO LO QUE ENTRENA” aparece el system prompt, la máscara no está funcionando. Si aparece la pregunta del usuario, tampoco. Si aparece vacío, el template no trae los marcadores {% generation %}. Y si aparece exactamente tu respuesta terminada en <|im_end|>, puedes entrenar tranquilo.
Truncación: dónde se corta y por qué importa
max_seq_length define el largo máximo de la secuencia; lo que sobra se corta. La decisión relevante es de qué lado.
La truncación por defecto es por la derecha, y en un dataset de fine-tuning eso es casi siempre lo peor posible: corta el final de la respuesta del asistente, que es justo lo único que estás entrenando. El resultado es un modelo entrenado con miles de respuestas incompletas, que en producción corta a mitad de frase.
Tres formas de manejarlo, en orden de preferencia:
- Sube
max_seq_lengthhasta cubrir el p99 de tus longitudes (el número que calculó el script del módulo 2). Es la solución correcta si la VRAM alcanza. El costo en memoria de las activaciones crece con la longitud, así que esto tiene un techo — tema del módulo 4. - Filtra los ejemplos que exceden el límite. Perder el 1% de los ejemplos es mucho mejor que entrenar con el 1% truncado, porque el ejemplo truncado enseña activamente a cortar.
- Recorta el contexto por la izquierda en el preprocesamiento, si el largo viene de la entrada y no de la respuesta. TRL solo conserva el inicio de la secuencia (
truncation_mode="keep_start"; la opción"keep_end"está deprecada), así que el recorte por la izquierda lo haces tú sobre el dataset — acortando el documento de contexto — antes de entregárselo al trainer. Sacrificas el principio del contexto, que suele ser lo menos crítico, y conservas la respuesta completa.
Lo que nunca corresponde es dejar la truncación por defecto sin haber mirado el percentil 99 de tus longitudes.
Packing: eficiencia con letra chica
El packing (glosar: concatenar varios ejemplos cortos en una sola secuencia del largo máximo, para no desperdiciar cómputo en tokens de relleno) resuelve un problema real: si tu max_seq_length es 2048 y tus ejemplos promedian 300 tokens, el 85% de cada batch es padding y estás pagando GPU por procesar relleno.
| Sin packing | Con packing | |
|---|---|---|
| Eficiencia de cómputo | Baja si los ejemplos son cortos | Alta, casi sin padding |
| Loss masking por respuesta | Directo con assistant_only_loss | Verifica que la máscara sobreviva al packing en tu versión |
| Contaminación entre ejemplos | Imposible | Posible sin máscara de límites |
| Cuándo conviene | Datasets chicos, ejemplos largos | Datasets grandes, ejemplos cortos |
| Recomendación del bloque | Empieza aquí | Cuando el costo lo justifique |
El riesgo específico del packing es la contaminación entre ejemplos: si dos conversaciones distintas quedan en la misma secuencia y no hay una máscara de atención que las separe, los tokens de la segunda pueden atender a los de la primera. El modelo aprende correlaciones entre ejemplos que no tienen ninguna relación. Las implementaciones modernas ofrecen máscaras que respetan los límites, pero es una capacidad que hay que verificar en la versión que estás usando, no asumir.
Para un primer fine-tuning: packing=False. Es más lento y es correcto. La optimización viene después de que el modelo funcione.
Errores comunes
- Armar el formato de chat concatenando strings a mano. Un salto de línea de diferencia con el template oficial es un token de diferencia, y el modelo entrena sobre una distribución que no es la suya. Usa
apply_chat_templatesiempre. - Entrenar sin loss masking. Con respuestas cortas sobre contexto largo, el 90% o más del gradiente está enseñándole al modelo a generar prompts de usuario. Es la causa número uno de fine-tunes que corren perfecto y no sirven.
- BOS duplicado.
apply_chat_templateya incluye los tokens especiales; volver a tokenizar conadd_special_tokens=Trueagrega otro. Entrenas con dos e infieres con uno. add_generation_prompt=Trueen entrenamiento. Agrega una apertura de turno vacía al final de cada ejemplo y el modelo aprende a producirla. EsTruesolo en inferencia.- Chat template sin los marcadores
{% generation %}.assistant_only_lossno tiene dónde apoyarse, la máscara queda vacía y no hay nada que entrenar. Verifica decodificando el ejemplo, o pasa el dataset aprompt/completion. - Copiar código con
DataCollatorForCompletionOnlyLMomax_seq_length. Es la API de TRL anterior a la 0.20: el collator ya no existe y el campo se llamamax_length. Si el tutorial los usa, es código viejo y va a fallar al importar. - Dejar la truncación por la derecha sin mirar las longitudes. Corta el final de la respuesta, que es exactamente lo que estás entrenando, y el modelo aprende a truncarse.
- Entrenar sin decodificar un batch primero. Es un minuto de trabajo que verifica las tres capas de una vez. Saltárselo es apostar horas de GPU a que todos los defaults calzaron.
- Usar el chat template de otro modelo. Un template de Llama sobre un Qwen produce respuestas degeneradas, y como el entrenamiento no falla, el diagnóstico aparece recién al probar el modelo terminado.
La frase para llevarse
“Si no decodificaste un batch, no sabes qué estás entrenando. La loss baja igual cuando el modelo está aprendiendo a escribir tus propias preguntas.”
Qué viene
Módulo 4: elegir el modelo base y el hardware, el cierre del bloque básico. Base o instruct y cuándo cada uno, qué tamaño pide realmente tu tarea, y el asunto de las licencias, que decide si puedes usar el resultado comercialmente. Después, la cuenta de VRAM completa y explícita — pesos, gradientes, estados del optimizador y activaciones — con la fórmula y un ejemplo numérico paso a paso, más la tabla de qué cabe en qué GPU para full fine-tuning, LoRA y QLoRA. Y dónde entrenar en la práctica: tu máquina, Colab, RunPod o Vast, Vertex AI o SageMaker, con órdenes de magnitud de costo por hora.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.