nikolas.cantillo
Caso FEVER / NotebookEjecutado · Tesla T4

El notebook, celda por celda

Fact-checking sobre FEVER tal cual fue desarrollado: código, outputs reales y análisis. Se omiten celdas de credenciales e instrucciones, y se filtra el ruido de progreso de los outputs.

Librerías

In [1]Python
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from datasets import Dataset
from sklearn.model_selection import train_test_split
from sklearn import metrics
from sklearn.metrics import (
    classification_report, 
    confusion_matrix, 
    precision_recall_fscore_support
)
import evaluate
import torch
from torch import cuda
from datasets import (
    Dataset, 
    Value, 
    ClassLabel, 
    Features, 
    DatasetDict
)
import transformers
from transformers import (
    AutoTokenizer, 
    RobertaModel, 
    RobertaTokenizer, 
    BertModel, 
    BertTokenizer, 
    DistilBertModel, 
    DistilBertTokenizer, 
    DataCollatorWithPadding,
    AutoModelForSequenceClassification,
    TrainingArguments,
    Trainer, 
    pipeline,
    AutoModelForCausalLM
)

import json
from tqdm import tqdm

import logging
logging.basicConfig(level=logging.ERROR)


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device
Out
device(type='cuda')

Introducción

En el contexto actual de la desinformación digital, la capacidad de verificar hechos de manera automática (Automated Fact-Checking) es una de las áreas más críticas del Procesamiento del Lenguaje Natural (NLP). Para esta tarea, trabajaremos con FEVER, uno de los datasets de referencia para la investigación en verificación de afirmaciones.

FEVER (Fact Extraction and VERification) es un conjunto de datos a gran escala que consta de más de 185.000 afirmaciones (claims) generadas a partir de Wikipedia. A diferencia de las tareas simples de clasificación de texto, FEVER requiere un razonamiento complejo que combina dos sub-tareas de NLP:

  • Recuperación de Información: Encontrar documentos relevantes que contengan la evidencia.

  • Inferencia del Lenguaje Natural: Determinar si la evidencia recuperada apoya o refuta la afirmación.

Cada entrada en el dataset presenta una afirmación y debe ser clasificada en una de las siguientes tres etiquetas basándose exclusivamente en la evidencia proporcionada (artículos de Wikipedia):

  • Supported (Respaldado): La evidencia contiene información suficiente para confirmar que la afirmación es verdadera.

  • Refuted (Refutado): La evidencia contiene información que contradice la afirmación (es falsa).

  • Not Enough Info (Información Insuficiente): No existe evidencia en la base de conocimiento dada para confirmar o negar la afirmación.

En esta tarea solo nos centraremos en dos clases: Supported y Refuted.

1 - Baseline: Uso de LLM como clasificador Zero-shot (6 puntos)

Cargue el dataset df_sample_fever.csv. Este conjunto de datos corresponde a una muestra aleatoria de 500 afirmaciones. Solo debe utilizar los campos claim y label.

Utilice el modelo meta-llama/Llama-3.2-3B-Instruct para obtener predicciones para cada afirmación (obtener label SUPPORTS o REFUTES). Justifique el diseño del prompt.

Calcule las métricas Precision, Recall y F1-score para el conjunto analizado (general y por clase). Comente los resultados.

Observaciones

  • Ver modelo en: https://huggingface.co/meta-llama/Llama-3.2-3B-Instruct
  • Para utilizar este modelo debe registrarse en huggingface y solicitar permiso. Al importarlo, debe ingresar el token de huggingface. Revisar la ayudantía 3.
  • Para esta pregunta deben trabajar con GPU.
  • El tiempo de procesamiento para las 500 afirmaciones es de 5 minutos, aproximadamente.
In [3]Python
MODEL_NAME = "meta-llama/Llama-3.2-3B-Instruct"

df = pd.read_csv("df_sample_fever.csv")

df = df[["claim", "label"]].dropna()

def normalize_label(x: str) -> str:
    x = str(x).strip().upper()
    if x in ["SUPPORTED", "SUPPORT", "SUPPORTS"]:
        return "SUPPORTS"
    if x in ["REFUTED", "REFUTE", "REFUTES"]:
        return "REFUTES"
    return x

df["label_norm"] = df["label"].apply(normalize_label)

df = df[df["label_norm"].isin(["SUPPORTS", "REFUTES"])].reset_index(drop=True)
print("Filas usadas:", len(df))
print(df["label_norm"].value_counts())
Out
Filas usadas: 500
label_norm
SUPPORTS    250
REFUTES     250
Name: count, dtype: int64
In [4]Python
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16,
    device_map="auto" 
)

Respuesta:

In [5]Python
import re
SYSTEM_MSG = (
    "You are an automated fact-checking classifier.\n"
    "Task: Given a CLAIM, output exactly one label:\n"
    "- SUPPORTS: the claim is true.\n"
    "- REFUTES: the claim is false.\n"
    "Rules:\n"
    "1) Output ONLY the label word: SUPPORTS or REFUTES.\n"
    "2) Do not add explanations, punctuation, or extra text.\n"
)

def build_messages(claim: str):
    return [
        {"role": "system", "content": SYSTEM_MSG},
        {"role": "user", "content": f"CLAIM: {claim}\nLABEL:"}
    ]

LABEL_RE = re.compile(r"\b(SUPPORTS|REFUTES)\b", re.IGNORECASE)

@torch.inference_mode()
def predict_one(claim: str) -> str:
    messages = build_messages(claim)
    inputs = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt"
    ).to(model.device)

    out = model.generate(
        inputs,
        max_new_tokens=3,
        do_sample=False,
        temperature=0.0,
        top_p=1.0
    )

    gen_text = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True).strip()
    m = LABEL_RE.search(gen_text)
    if m:
        return m.group(1).upper()

    return "REFUTES"

preds = []
for i, claim in enumerate(df["claim"].tolist(), start=1):
    preds.append(predict_one(claim))
    if i % 50 == 0:
        print(f"Procesadas: {i}/{len(df)}")

df["pred"] = preds

y_true = df["label_norm"].tolist()
y_pred = df["pred"].tolist()

print("\n=== Classification report ===")
print(classification_report(y_true, y_pred, labels=["SUPPORTS", "REFUTES"], digits=4))

p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support(
    y_true, y_pred, average="macro", labels=["SUPPORTS", "REFUTES"]
)
p_micro, r_micro, f1_micro, _ = precision_recall_fscore_support(
    y_true, y_pred, average="micro", labels=["SUPPORTS", "REFUTES"]
)

print("\nMacro  P/R/F1:", p_macro, r_macro, f1_macro)
print("Micro  P/R/F1:", p_micro, r_micro, f1_micro)

df.to_csv("fever_llama32_3b_zeroshot_preds.csv", index=False)
print("\nGuardado: fever_llama32_3b_zeroshot_preds.csv")
Out
c:\Users\DELL\.conda\envs\NLP\Lib\site-packages\transformers\generation\configuration_utils.py:628: UserWarning: `do_sample` is set to `False`. However, `temperature` is set to `0.0` -- this flag is only used in sample-based generation modes. You should set `do_sample=True` or unset `temperature`.
  warnings.warn(
The attention mask is not set and cannot be inferred from input because pad token is same as eos token. As a consequence, you may observe unexpected behavior. Please pass your input's `attention_mask` to obtain reliable results.=== Classification report ===
              precision    recall  f1-score   support

    SUPPORTS     0.6667    0.6160    0.6403       250
     REFUTES     0.6431    0.6920    0.6667       250

    accuracy                         0.6540       500
   macro avg     0.6549    0.6540    0.6535       500
weighted avg     0.6549    0.6540    0.6535       500

Macro  P/R/F1: 0.6548946716232962 0.6539999999999999 0.6534996534996536
Micro  P/R/F1: 0.654 0.654 0.654

Guardado: fever_llama32_3b_zeroshot_preds.csv

El modelo meta-llama/Llama-3.2-3B-Instruct usado en modo zero-shot logra un rendimiento global de 0.654 (accuracy y micro-F1) y un macro-F1 de 0.6535. En otras palabras, funciona se podria decir que de una forma decente como línea base y se comporta de forma bastante similar en ambas clases.

Mirando por etiqueta, el modelo detecta mejor los casos REFUTES (recall 0.692) que los SUPPORTS (recall 0.616). Esto sugiere que, cuando el modelo no está completamente seguro, tiende a inclinarse por refutar más seguido que por respaldar.

Estos resultados tienen sentido dado el enfoque: como no se recupera evidencia desde Wikipedia, el modelo no “verifica” realmente, sino que decide apoyándose en lo que ya sabe (conocimiento aprendido durante el entrenamiento) y en qué tan plausible suena la afirmación. Eso puede fallar especialmente en claims muy específicos, de nicho o que requieren contexto. Por eso, este baseline es útil como punto de partida, pero lo esperable es mejorar de forma clara al pasar a un pipeline FEVER completo (recuperación de evidencia + verificación con NLI).

2 - Creación de base de conocimiento (10 puntos)

Para sustentar el proceso de verificación de afirmaciones, se debe construir una base de conocimiento basada en Wikipedia, utilizando LangChain. El objetivo es implementar un flujo de recuperación de información que permita obtener evidencia factual de manera automatizada.

Para lograr esto, se deben seguir las siguientes instrucciones:

  1. Se debe implementar una función de extracción de entidades para cada afirmación. Esta entidad servirá como el parámetro principal de consulta para localizar los artículos más relevantes en Wikipedia. Se recomienda utilizar el modelo Llama-3.2-3B-Instruct para esta tarea.

  2. Se debe recuperar el contenido textual de los artículos identificados en Wikipedia, en función de las entidades reconocidas en el punto 1.

  3. Se debe construir una base vectorial utilizando FAISS. Este paso requiere definir una estrategia de segmentación de texto (chunks) y transformar los segmentos en embeddings para permitir búsquedas por similitud.

Observaciones

  • Se recomienda revisar la ayudantía 4.

  • La definición de hiperparámetros queda a criterio del modelador. Por ejemplo, el tamaño de los chunks, solapamiento (overlap), cantidad de ejemplos retornados por FAISS al hacer la búsqueda, entre otros.

  • Dadas las limitaciones del modelo Llama-3.2-3B, es posible que algunas entidades no sean factibles de reconocer (punto 1). Eso se entiende como una limitación del sistema. No afectará la calificación.

  • La construcción de esta base de datos vectorial podría tomar cerca de 20 minutos, dependiendo del pipeline diseñado. Se recomienda guardar en un dataframe todos los fragmentos de texto para no repetir la búsqueda de entidades en wikipedia.

Respuesta:

In [15]Python
import os
import re
import time
import pandas as pd
from tqdm import tqdm


from langchain_community.document_loaders import WikipediaLoader
from langchain_community.vectorstores import FAISS
from langchain_community.embeddings import HuggingFaceEmbeddings

try:
    from langchain_text_splitters import RecursiveCharacterTextSplitter
except ImportError:
    from langchain.text_splitter import RecursiveCharacterTextSplitter


OUT_DIR = "kb_fever_faiss_light"
os.makedirs(OUT_DIR, exist_ok=True)

ENTITIES_CSV = os.path.join(OUT_DIR, "claim_entities.csv")
CHUNKS_CSV   = os.path.join(OUT_DIR, "kb_chunks.csv")
FAISS_DIR    = os.path.join(OUT_DIR, "faiss_index")

LOAD_MAX_DOCS = 1
DOC_CHARS_MAX = 4000

CHUNK_SIZE = 700
CHUNK_OVERLAP = 80

EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"

EMB_DEVICE = "cuda:0" if torch.cuda.is_available() else "cpu"

print("GPU (torch):", torch.cuda.is_available(), "| Embeddings device:", EMB_DEVICE)

SYSTEM_ENTITY_FAST = (
    "Extract the single main Wikipedia page title for this claim.\n"
    "Return ONLY the title, no extra text, no punctuation.\n"
    "If unsure, return the most central named entity."
)

def _fallback_entity_from_claim(claim: str) -> str:
    m = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b", claim)
    if m:
        return m[0].strip()
    return claim[:60].strip()

@torch.inference_mode()
def extract_entity_fast(claim: str) -> str:
    """
    Usa Llama para extraer 1 entidad (título de Wikipedia).
    Requiere que tokenizer y model ya existan en el notebook.
    """
    messages = [
        {"role": "system", "content": SYSTEM_ENTITY_FAST},
        {"role": "user", "content": f"CLAIM: {claim}\nTITLE:"}
    ]
    inputs = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt"
    ).to(model.device)

    out = model.generate(
        inputs,
        max_new_tokens=12,
        do_sample=False,
        temperature=0.0,
        top_p=1.0
    )

    txt = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True).strip()

    # Limpieza básica
    txt = txt.split("\n")[0].strip()
    txt = re.sub(r"^[\-\:\s]+", "", txt)
    txt = re.sub(r"[\"`\']+", "", txt).strip()

    # fallback si quedó vacío o raro
    if len(txt) < 2:
        txt = _fallback_entity_from_claim(claim)

    return txt[:80].strip()


def load_wiki(entity: str):
    loader = WikipediaLoader(
        query=entity,
        load_max_docs=LOAD_MAX_DOCS,
        doc_content_chars_max=DOC_CHARS_MAX
    )
    return loader.load()


df = pd.read_csv("df_sample_fever.csv")[["claim", "label"]].dropna().reset_index(drop=True)

if os.path.exists(ENTITIES_CSV):
    print(f"[CACHE] Cargando entidades desde: {ENTITIES_CSV}")
    df = pd.read_csv(ENTITIES_CSV)
else:
    entities = []
    t0 = time.time()
    for claim in tqdm(df["claim"].tolist(), desc="Entity extraction (Llama)"):
        entities.append(extract_entity_fast(claim))
    df["entity"] = entities
    df.to_csv(ENTITIES_CSV, index=False)
    print(f"[OK] Entidades guardadas en: {ENTITIES_CSV} | tiempo: {time.time()-t0:.1f}s")

print("Ejemplo entity:", df.loc[0, "entity"])


if os.path.exists(CHUNKS_CSV):
    print(f"[CACHE] Cargando chunks desde: {CHUNKS_CSV}")
    chunks_df = pd.read_csv(CHUNKS_CSV)
else:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP
    )

    unique_entities = sorted(set(df["entity"].dropna().tolist()))
    print("Unique entities:", len(unique_entities))

    rows = []
    wiki_cache = {}

    t0 = time.time()
    for ent in tqdm(unique_entities, desc="Wikipedia retrieval"):
        if ent in wiki_cache:
            docs = wiki_cache[ent]
        else:
            try:
                docs = load_wiki(ent)
            except Exception:
                docs = []
            wiki_cache[ent] = docs

        if not docs:
            rows.append({
                "entity": ent,
                "page_title": None,
                "chunk_id": None,
                "chunk": None
            })
            continue

        for d in docs:
            title = d.metadata.get("title", "unknown") if d.metadata else "unknown"
            chunks = splitter.split_text(d.page_content)

            for j, ch in enumerate(chunks):
                rows.append({
                    "entity": ent,
                    "page_title": title,
                    "chunk_id": j,
                    "chunk": ch
                })

    chunks_df = pd.DataFrame(rows)
    chunks_df.to_csv(CHUNKS_CSV, index=False)
    print(f"[OK] Chunks guardados en: {CHUNKS_CSV} | tiempo: {time.time()-t0:.1f}s")

print("Total chunks (incluye None):", len(chunks_df))
print("Chunks válidos:", chunks_df["chunk"].notna().sum())


embeddings = HuggingFaceEmbeddings(
    model_name=EMBEDDING_MODEL,
    model_kwargs={"device": EMB_DEVICE}
)

if os.path.exists(FAISS_DIR):
    print(f"[CACHE] Cargando FAISS desde: {FAISS_DIR}")
    vectorstore = FAISS.load_local(FAISS_DIR, embeddings, allow_dangerous_deserialization=True)
else:
    valid_df = chunks_df.dropna(subset=["chunk"]).reset_index(drop=True)

    texts = valid_df["chunk"].tolist()
    metadatas = valid_df[["entity", "page_title", "chunk_id"]].to_dict(orient="records")

    t0 = time.time()
    vectorstore = FAISS.from_texts(texts=texts, embedding=embeddings, metadatas=metadatas)
    vectorstore.save_local(FAISS_DIR)
    print(f"[OK] FAISS guardado en: {FAISS_DIR} | tiempo: {time.time()-t0:.1f}s")

print("KB lista")
Out
GPU (torch): True | Embeddings device: cuda:0
[CACHE] Cargando entidades desde: kb_fever_faiss_light\claim_entities.csv
Ejemplo entity: The Blacklist
[CACHE] Cargando chunks desde: kb_fever_faiss_light\kb_chunks.csv
Total chunks (incluye None): 4149
Chunks válidos: 4125
[CACHE] Cargando FAISS desde: kb_fever_faiss_light\faiss_index
KB lista

3 - Aplicación de RAG (10 puntos)

Utilice la base de datos vectorial creada en el punto 2 como fuente de conocimiento externo para contextualizar el modelo de lenguaje meta-llama/Llama-3.2-3B-Instruct.

Diseñe un prompt que permita realizar predicciones en función del contexto recuperado desde dicha base vectorial.

Calcule las métricas Precision, Recall y F1-score para el conjunto analizado (general y por clase). Comente los resultados.

Respuesta:

In [14]Python
df = pd.read_csv("df_sample_fever.csv")[["claim", "label"]].dropna().reset_index(drop=True)

def normalize_label(x: str) -> str:
    x = str(x).strip().upper()
    if x in ["SUPPORTED", "SUPPORT", "SUPPORTS"]:
        return "SUPPORTS"
    if x in ["REFUTED", "REFUTE", "REFUTES"]:
        return "REFUTES"
    return x

df["label_norm"] = df["label"].apply(normalize_label)
df = df[df["label_norm"].isin(["SUPPORTS", "REFUTES"])].reset_index(drop=True)

print("Filas usadas:", len(df))
print(df["label_norm"].value_counts())


TOP_K = 5       
MAX_EVID_CHARS = 2600

def retrieve_docs(query: str, k: int = TOP_K):
    return vectorstore.similarity_search(query, k=k)

def format_evidence(docs, max_chars: int = MAX_EVID_CHARS) -> str:
    parts = []
    total = 0
    for i, d in enumerate(docs, start=1):
        title = d.metadata.get("page_title", d.metadata.get("title", "unknown"))
        chunk = d.page_content.strip().replace("\n", " ")
        snippet = f"[{i}] ({title}) {chunk}"
        if total + len(snippet) > max_chars:
            break
        parts.append(snippet)
        total += len(snippet)
    return "\n".join(parts)


SYSTEM_RAG = (
    "You are a fact-checking assistant.\n"
    "You will receive a CLAIM and EVIDENCE passages from Wikipedia.\n"
    "Classify the claim using ONLY the evidence.\n"
    "Return exactly one label:\n"
    "- SUPPORTS (evidence supports the claim)\n"
    "- REFUTES (evidence contradicts the claim)\n"
    "Rules:\n"
    "1) Use ONLY the provided evidence.\n"
    "2) Output ONLY one word: SUPPORTS or REFUTES.\n"
    "3) No explanations, no punctuation, no extra text.\n"
)

LABEL_RE = re.compile(r"\b(SUPPORTS|REFUTES)\b", re.IGNORECASE)

@torch.inference_mode()
def predict_rag(claim: str) -> str:
    docs = retrieve_docs(claim, k=TOP_K)
    evidence = format_evidence(docs)

    messages = [
        {"role": "system", "content": SYSTEM_RAG},
        {"role": "user", "content": f"CLAIM:\n{claim}\n\nEVIDENCE:\n{evidence}\n\nLABEL:"}
    ]

    inputs = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt"
    ).to(model.device)

    out = model.generate(
        inputs,
        max_new_tokens=3,
        do_sample=False,
        temperature=0.0,
        top_p=1.0
    )

    gen = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True).strip()
    m = LABEL_RE.search(gen)
    if m:
        return m.group(1).upper()

    return "REFUTES"


preds = []
for claim in tqdm(df["claim"].tolist(), desc="RAG predictions"):
    preds.append(predict_rag(claim))

df["pred_rag"] = preds


out_path = "fever_llama32_3b_rag_preds.csv"
df.to_csv(out_path, index=False)
print("Guardado:", out_path)


y_true = df["label_norm"].tolist()
y_pred = df["pred_rag"].tolist()

print("\n=== Classification report (RAG) ===")
print(classification_report(y_true, y_pred, labels=["SUPPORTS", "REFUTES"], digits=4))

p_macro, r_macro, f1_macro, _ = precision_recall_fscore_support(
    y_true, y_pred, average="macro", labels=["SUPPORTS", "REFUTES"]
)
p_micro, r_micro, f1_micro, _ = precision_recall_fscore_support(
    y_true, y_pred, average="micro", labels=["SUPPORTS", "REFUTES"]
)

print("\nMacro  P/R/F1:", p_macro, r_macro, f1_macro)
print("Micro  P/R/F1:", p_micro, r_micro, f1_micro)
Out
Filas usadas: 500
label_norm
SUPPORTS    250
REFUTES     250
Name: count, dtype: int64warnings.warn(Guardado: fever_llama32_3b_rag_preds.csv

=== Classification report (RAG) ===
              precision    recall  f1-score   support

    SUPPORTS     0.6842    0.5200    0.5909       250
     REFUTES     0.6129    0.7600    0.6786       250

    accuracy                         0.6400       500
   macro avg     0.6486    0.6400    0.6347       500
weighted avg     0.6486    0.6400    0.6347       500

Macro  P/R/F1: 0.6485568760611206 0.64 0.6347402597402598
Micro  P/R/F1: 0.64 0.64 0.64

4 - Conclusiones (4 puntos)

Compare los resultados obtenidos por cada solución implementada.

Respuesta:

Comparando las dos soluciones, el baseline zero-shot termina rindiendo un poco mejor en general (accuracy/micro-F1 = 0.654) que el enfoque RAG (0.640, con un dataset balanceado de 250/250 por clase). Aunque RAG incorpora evidencia desde Wikipedia, el rendimiento global baja principalmente porque empeora SUPPORTS: su recall cae de 0.616 a 0.520, lo que sugiere que en muchos casos la evidencia recuperada no es lo suficientemente clara o relevante para confirmar afirmaciones verdaderas.

Al mismo tiempo, RAG sí ayuda en REFUTES: el recall sube de 0.692 a 0.760, lo que indica que la evidencia recuperada suele ser más útil para encontrar contradicciones que para respaldar un hecho. En conjunto, cuando el contexto no es concluyente, el sistema tiende a refutar con más frecuencia, lo que favorece REFUTES pero perjudica SUPPORTS.

En conclusión, el principal cuello de botella parece estar en la etapa de recuperación (calidad de chunks, embeddings, top-k y estrategia de consulta). Ajustes como recuperar por entidad, aumentar top-k, mejorar el chunking o incorporar un re-ranker probablemente permitirían que RAG supere al baseline en una iteración posterior.

← Volver al casoComentar este trabajo