Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Este es el ejercicio calificado central del capítulo. La regla que enseña:

Antes de confiarle una tarea a un agente, construya el conjunto de evaluación que lo mide.

Usted ya cree en esta regla para los modelos — el capítulo 3 lo hizo evaluar cada clasificador contra un conjunto de prueba reservado con verdad de referencia, y la tabla de clasificación (leaderboard) de la clase lo calificó sobre datos que usted nunca vio. Un agente es un modelo con herramientas. Misma regla, misma maquinaria: defina la tarea con precisión, reúna casos con respuestas conocidas, califique automáticamente, estudie las fallas. La diferencia es que ahora usted genera la verdad de referencia, y esta es exactamente la razón por la que los datos sintéticos con motivación física han recorrido todo el curso: mlgeo_synth le da casos de evaluación ilimitados con verdad exacta y gratuita.

Trabajamos un ejemplo de principio a fin y después usted repite el procedimiento sobre una tarea del dominio de su propio proyecto.

La tarea objetivo del agente:

Dada una serie cruda de desplazamiento GNSS diario, reporte la velocidad secular en mm/año y señale cualquier desplazamiento cosísmico.

Una nota sobre el montaje: el «agente» de este cuaderno es una función de Python que hace las veces de uno real, con modos de falla realistas, de modo que el cuaderno corre sin conexión y en CI. La especificación, el conjunto de evaluación, el calificador y el bucle son exactamente lo que usted envolvería alrededor de un agente en vivo — pero un agente LLM en vivo, muestreado, añade tres requisitos que el sustituto determinista no tiene: validar su esquema de salida antes de calificar (paso 3), correr repeticiones por caso y reportar tasas de éxito («Calificar sin verdad computable», abajo), y llevar registro del costo y la latencia. Este cuaderno construye los dos primeros; el tercero es contabilidad.

🖥️ Diapositivas — Sesión 19 (vie 13 nov)

Paso 1: Escriba la especificación de la tarea

La mayoría de las «fallas de agente» son fallas de especificación. Una especificación no es un deseo («analiza estos datos GNSS»); es un contrato lo bastante preciso como para que aprobar o reprobar sea computable. La nuestra, versión 1:

SPEC_V1 = """
TASK: GNSS series characterization, spec v1

INPUT: a DataFrame with columns `date` (daily) and `disp_mm` (one component,
millimeters). No other columns may be used.

OUTPUT: a dict with exactly these keys:
  velocity_mm_yr : float  — secular velocity
  offset         : bool   — True if a coseismic offset is present
  offset_day     : int or None — day index of the offset if offset is True

TOLERANCES (a case passes only if ALL hold):
  |velocity_mm_yr - truth| <= 1.5 mm/yr
  offset flag matches truth
  if an offset exists and is flagged: |offset_day - truth| <= 30 days
"""
print(SPEC_V1)

TASK: GNSS series characterization, spec v1

INPUT: a DataFrame with columns `date` (daily) and `disp_mm` (one component,
millimeters). No other columns may be used.

OUTPUT: a dict with exactly these keys:
  velocity_mm_yr : float  — secular velocity
  offset         : bool   — True if a coseismic offset is present
  offset_day     : int or None — day index of the offset if offset is True

TOLERANCES (a case passes only if ALL hold):
  |velocity_mm_yr - truth| <= 1.5 mm/yr
  offset flag matches truth
  if an offset exists and is flagged: |offset_day - truth| <= 30 days

Note qué queda fijado: las columnas de entrada (para que el agente no pueda espiar las columnas de verdad), el esquema de salida (para que la calificación sea mecánica) y las tolerancias numéricas (para que «aprobar» no sea un estado de ánimo). Note también en qué no pensamos todavía: longitud del registro, nivel de ruido, tamaño del desplazamiento. El conjunto de evaluación está a punto de castigarnos por eso, que es justamente el sentido de construirlo.

Paso 2: Genere el conjunto de evaluación

Doce casos que barren la dificultad: líneas base limpias, señal estacional fuerte, baja relación señal-ruido, desplazamientos de grandes a diminutos, registros cortos y una velocidad negativa para que se pongan a prueba las convenciones de signo. Cada caso es una llamada al generador más su verdad de referencia — que conocemos porque nosotros la fijamos.

import numpy as np
import pandas as pd
from mlgeo_synth import gnss_series

CASES = [
    # (case_id, description, generator parameters)
    ("C01", "clean, 10 yr, no offset",
     dict(n_years=10, velocity_mm_yr=12, eq_day=None, seed=101)),
    ("C02", "clean, 10 yr, 25 mm offset",
     dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=25, postseismic_mm=0, seed=102)),
    ("C03", "strong seasonal (8 mm), no offset",
     dict(n_years=10, velocity_mm_yr=6, annual_mm=8, eq_day=None, seed=103)),
    ("C04", "strong seasonal, 20 mm offset",
     dict(n_years=10, velocity_mm_yr=6, annual_mm=8, eq_day=2000, coseismic_mm=20, postseismic_mm=0, seed=104)),
    ("C05", "low SNR, no offset",
     dict(n_years=10, velocity_mm_yr=3, white_mm=3, flicker_mm=6, eq_day=None, seed=105)),
    ("C06", "low SNR, 25 mm offset",
     dict(n_years=10, velocity_mm_yr=3, white_mm=3, flicker_mm=6, eq_day=1500, coseismic_mm=25, postseismic_mm=0, seed=106)),
    ("C07", "small offset, 8 mm",
     dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=8, postseismic_mm=0, seed=107)),
    ("C08", "very small offset, 4 mm",
     dict(n_years=10, velocity_mm_yr=12, eq_day=1500, coseismic_mm=4, postseismic_mm=0, seed=108)),
    ("C09", "short record, 2 yr, no offset",
     dict(n_years=2, velocity_mm_yr=12, eq_day=None, seed=109)),
    ("C10", "short record, 2 yr, 25 mm offset",
     dict(n_years=2, velocity_mm_yr=12, eq_day=365, coseismic_mm=25, postseismic_mm=0, seed=110)),
    ("C11", "short + strong seasonal, no offset",
     dict(n_years=2, velocity_mm_yr=6, annual_mm=8, eq_day=None, seed=111)),
    ("C12", "negative velocity, offset near end",
     dict(n_years=10, velocity_mm_yr=-8, eq_day=3000, coseismic_mm=15, postseismic_mm=0, seed=112)),
]


def load_case(params):
    """Materialize one case: (input the agent sees, ground truth it does not)."""
    df = gnss_series(**params)
    truth = {
        "velocity_mm_yr": params["velocity_mm_yr"],
        "offset": params.get("eq_day") is not None,
        "offset_day": params.get("eq_day"),
        "n_years": params["n_years"],
    }
    return df[["date", "disp_mm"]], truth   # agent gets ONLY raw columns


print(f"{len(CASES)} cases")
12 cases
import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(9, 5), sharex=False)
for ax, cid in zip(axes.ravel(), ["C01", "C04", "C06", "C08"]):
    desc, params = next((d, p) for c, d, p in CASES if c == cid)
    df, truth = load_case(params)
    ax.plot(np.arange(len(df)), df["disp_mm"], lw=0.4)
    if truth["offset"]:
        ax.axvline(truth["offset_day"], color="tab:red", ls="--", lw=1)
    ax.set_title(f"{cid}: {desc}", fontsize=9)
    ax.set_xlabel("day")
    ax.set_ylabel("disp (mm)")
fig.suptitle("Four of the twelve eval cases (red dashes: true offset day)")
fig.tight_layout()
<Figure size 900x500 with 4 Axes>

Mire C08 antes de seguir: un desplazamiento de 4 mm bajo ruido de color de nivel milimétrico es invisible a la vista. Si el agente debe estar obligado a encontrarlo es una pregunta científica genuina — y escribir el conjunto de evaluación nos forzó a planteárnosla, antes de que corriera agente alguno. El diseño de cobertura en una oración: barra cada perilla que cambie la física del problema (tamaño de la señal, ruido, longitud del registro, señales que confunden), incluya casos donde la respuesta correcta sea «no hay nada ahí», e incluya al menos un caso cerca del límite de la detectabilidad física.

Paso 3: Escriba el calificador

Mecánico, basado en tolerancias, sin juicios en el momento de calificar — todo el juicio se fue a la especificación. Y una propiedad innegociable: el calificador debe sobrevivir a salidas malformadas. La falla más común de un agente LLM en vivo no es un número equivocado; es una salida que viola el esquema — una clave faltante, un número envuelto en prosa, una cadena donde va un flotante. Un calificador que lanza KeyError ante esa salida no puede registrar precisamente la falla que más necesita registrar. Por eso score_case primero valida el resultado contra el esquema de salida de la especificación y convierte cualquier violación en una falla calificada con un motivo, nunca en una excepción. (La rúbrica de calificación del final otorga puntos de solidez del calificador exactamente por este comportamiento — el ejemplo desarrollado muestra ahora para qué son esos puntos.)

RESULT_SCHEMA = {
    # key -> types the spec allows
    "velocity_mm_yr": (int, float),
    "offset": (bool, np.bool_),
    "offset_day": (int, np.integer, type(None)),
}


def validate_result(result):
    """Check an agent result against the spec's output schema.

    Returns a list of problems; an empty list means the schema holds.
    """
    if not isinstance(result, dict):
        return [f"not a dict: got {type(result).__name__}"]
    problems = [f"missing key '{k}'" for k in RESULT_SCHEMA if k not in result]
    for k, types in RESULT_SCHEMA.items():
        if k in result and not isinstance(result[k], types):
            problems.append(f"'{k}' has type {type(result[k]).__name__}")
    return problems


def score_case(result, truth, vel_tol=1.5, day_tol=30):
    """Apply the spec's tolerances to one agent result. Returns per-check booleans.

    Malformed output is a scored failure with a reason — never a crash.
    """
    problems = validate_result(result)
    if problems:
        return {"vel_ok": False, "offset_ok": False, "pass": False,
                "malformed": "; ".join(problems)}
    vel_ok = abs(result["velocity_mm_yr"] - truth["velocity_mm_yr"]) <= vel_tol
    flag_ok = bool(result["offset"]) == truth["offset"]
    day_ok = True
    if truth["offset"] and result["offset"]:
        day_ok = abs(result["offset_day"] - truth["offset_day"]) <= day_tol
    return {"vel_ok": vel_ok, "offset_ok": flag_ok and day_ok,
            "pass": vel_ok and flag_ok and day_ok, "malformed": ""}


def run_eval(agent, vel_tol_fn=lambda truth: 1.5):
    """Run an agent over all cases; return one row per case."""
    rows = []
    for case_id, desc, params in CASES:
        df, truth = load_case(params)
        result = agent(df)
        s = score_case(result, truth, vel_tol=vel_tol_fn(truth))
        valid = not s["malformed"]
        rows.append({
            "case": case_id, "description": desc,
            "true_v": truth["velocity_mm_yr"],
            "est_v": round(result["velocity_mm_yr"], 2) if valid else None,
            "true_offset": truth["offset"],
            "flagged": result["offset"] if valid else None,
            **s,
        })
    return pd.DataFrame(rows).set_index("case")

Demuestre que la protección funciona antes de confiar en ella — un resultado deliberadamente malformado, del tipo que produce un agente en vivo cuando envuelve el número en prosa y olvida una clave:

bad_result = {"velocity_mm_yr": "about 12 mm/yr", "offset": False}  # string; no offset_day
_, truth_c01 = load_case(CASES[0][2])
score_case(bad_result, truth_c01)
{'vel_ok': False, 'offset_ok': False, 'pass': False, 'malformed': "missing key 'offset_day'; 'velocity_mm_yr' has type str"}

Paso 4: Córralo contra el agente

Aquí está el agente simulado. Su estrategia es la ingenua que produce un analista apresurado (humano o artificial): un ajuste de línea recta para la velocidad, ignorando los términos estacionales y los desplazamientos, y detección de desplazamientos por barrido del mayor salto entre medias de 30 días. Sus modos de falla están plantados pero son realistas: velocidad sesgada cuando hay una señal no modelada, desplazamientos pequeños que se pierden, excursiones de ruido confundidas con desplazamientos.

def agent_v1(df):
    """Simulated agent, version 1: straight-line velocity + jump detector."""
    d = df["disp_mm"].to_numpy()
    t_yr = np.arange(len(d)) / 365.25
    velocity = np.polyfit(t_yr, d, 1)[0]          # ignores seasonal & offsets

    best_jump, best_day = 0.0, None
    for day in range(45, len(d) - 45, 5):          # largest 30-day-mean jump
        jump = abs(d[day:day + 30].mean() - d[day - 30:day].mean())
        if jump > best_jump:
            best_jump, best_day = jump, day
    detected = best_jump > 12                      # hard-coded threshold, mm
    return {"velocity_mm_yr": velocity, "offset": detected,
            "offset_day": best_day if detected else None}


res_v1 = run_eval(agent_v1)
res_v1
Loading...
print(f"agent v1: {int(res_v1['pass'].sum())}/12 cases pass "
      f"(velocity: {int(res_v1['vel_ok'].sum())}/12, "
      f"offset: {int(res_v1['offset_ok'].sum())}/12)")
agent v1: 3/12 cases pass (velocity: 6/12, offset: 9/12)

Paso 5: Analice las fallas, apriete, vuelva a correr

Tres de doce. Sin el conjunto de evaluación, este agente se habría lanzado a producción: sus respuestas sobre cualquier serie individual parecen razonables. Lea las fallas por grupo — la tabla es el diagnóstico:

  • C02, C04, C06, C10 — velocidad equivocada siempre que hay un desplazamiento. El ajuste de línea recta absorbe en la pendiente el escalón no modelado. En el registro de 2 años (C10) el sesgo llega a ~18 mm/año. Modo de falla correlacionado: las dos salidas no son independientes, así que un término del modelo que falta corrompe un número reportado.
  • C07, C08 — desplazamientos pequeños que se pierden. El umbral fijo de 12 mm no puede ver escalones de 8 ni de 4 mm. La especificación nunca dijo cuál debía ser el desplazamiento mínimo detectable; el conjunto de evaluación simplemente hizo visible ese hueco.
  • C05 — falsa alarma. El ruido flicker (de parpadeo) deambula lo suficiente como para que algunas medias de 30 días difieran en más de 12 mm sin sismo alguno. Los casos «sin desplazamiento» se ganan su lugar en un conjunto de evaluación: un detector solo tiene sentido si puede quedarse callado.
  • C09, C11 — velocidad equivocada en registros cortos incluso sin desplazamiento. Dos años de señal estacional más ruido de color derrotan a un ajuste de línea recta.

El análisis de fallas dicta la corrección, y es tanto una corrección de la especificación como del agente. La especificación v2 añade una cláusula de método obligatoria — «estime la velocidad por mínimos cuadrados con términos anual y semianual y, si se señala un desplazamiento, una función escalón en el día detectado» — el modelo físico del capítulo 2. El agente mejorado lo implementa:

def agent_v2(df):
    """Simulated agent, version 2: fits trend + seasonal + step (spec v2)."""
    d = df["disp_mm"].to_numpy()
    n = len(d)
    t = np.arange(n)
    t_yr = t / 365.25

    best_jump, best_day = 0.0, None
    for day in range(45, n - 45, 5):
        jump = abs(d[day:day + 30].mean() - d[day - 30:day].mean())
        if jump > best_jump:
            best_jump, best_day = jump, day

    G = np.column_stack([
        np.ones(n), t_yr,
        np.sin(2 * np.pi * t_yr), np.cos(2 * np.pi * t_yr),
        np.sin(4 * np.pi * t_yr), np.cos(4 * np.pi * t_yr),
        (t >= best_day).astype(float),
    ])
    coef, *_ = np.linalg.lstsq(G, d, rcond=None)
    detected = abs(coef[-1]) > 6      # decide on the FITTED step amplitude
    if not detected:                   # refit without the step term
        coef, *_ = np.linalg.lstsq(G[:, :-1], d, rcond=None)
    return {"velocity_mm_yr": coef[1], "offset": detected,
            "offset_day": best_day if detected else None}


res_v2 = run_eval(agent_v2)
print(f"agent v2: {int(res_v2['pass'].sum())}/12 cases pass")
res_v2[~res_v2["pass"]]
agent v2: 10/12 cases pass
Loading...

Diez de doce, y toda falla de física desapareció: se encuentran desplazamientos de hasta 4 mm (decidir con la amplitud del escalón ajustado supera al umbral sobre saltos crudos), la falsa alarma de baja relación señal-ruido desaparece, las velocidades son correctas dondequiera que el modelo sea adecuado.

Los dos sobrevivientes — C09 y C10 — son ambos registros de 2 años, y sus errores de velocidad (~1.7–2 mm/año) no son culpa del agente. La incertidumbre de la velocidad debida al ruido de color del GNSS escala aproximadamente como 1/T: dos años de ruido flicker no pueden entregar ±1.5 mm/año, sin importar quién los analice. Nuestra especificación exigía algo que los datos no pueden dar. Esto es lo segundo que atrapan los conjuntos de evaluación: las especificaciones irrazonables.

¿Hasta dónde debe relajarse la tolerancia, entonces? No hasta lo que haga desaparecer las fallas — eso es calificar sobre una curva que usted dibujó después de ver el examen. La tolerancia es una propiedad del estimador bajo la física, y el generador es nuestro, así que podemos medirla: corra el estimador sobre muchas realizaciones nuevas sin desplazamiento para cada longitud de registro y mire la distribución de su error.

def velocity_error_spread(n_years, n_trials=50, truth_v=12.0):
    """|velocity error| of agent_v2 over fresh offset-free realizations."""
    errs = []
    for s in range(1000, 1000 + n_trials):   # seeds disjoint from the eval set
        df = gnss_series(n_years=n_years, velocity_mm_yr=truth_v, eq_day=None, seed=s)
        est = agent_v2(df[["date", "disp_mm"]])["velocity_mm_yr"]
        errs.append(abs(est - truth_v))
    return np.array(errs)


for T in (10, 2):
    e = velocity_error_spread(T)
    print(f"{T:>2}-yr records, 50 seeds: median |error| {np.median(e):.2f} mm/yr, "
          f"95th percentile {np.quantile(e, 0.95):.2f} mm/yr")
10-yr records, 50 seeds: median |error| 0.09 mm/yr, 95th percentile 0.28 mm/yr
 2-yr records, 50 seeds: median |error| 0.78 mm/yr, 95th percentile 1.85 mm/yr

La distribución responde la pregunta. Registros de diez años: error en el percentil 95 cercano a 0.3 mm/año, así que los ±1.5 mm/año originales son holgados. Registros de dos años: el percentil 95 se sitúa cerca de 1.9 mm/año — ±1.5 nunca fue alcanzable, y una tolerancia de ±3 mm/año (unas 1.5 veces el percentil 95 medido, con margen porque 50 repeticiones fijan la cola solo de forma laxa y muestreamos una única velocidad verdadera) es lo que la física sostiene. La especificación v3 escribe esa medición dentro del contrato. La regla general: derive las tolerancias de realizaciones del estimador, no de una primera conjetura — con un generador sintético, la derivación cuesta cinco líneas.

def vel_tol_v3(truth):
    # 1.5 covers the measured 10-yr error spread with wide margin;
    # 3.0 ~ 1.5x the measured 95th-percentile error on 2-yr records.
    return 1.5 if truth["n_years"] >= 5 else 3.0


res_v3 = run_eval(agent_v2, vel_tol_fn=vel_tol_v3)
summary = pd.DataFrame({
    "agent v1, spec v1": res_v1["pass"],
    "agent v2, spec v2": res_v2["pass"],
    "agent v2, spec v3": res_v3["pass"],
})
print(f"final: {int(res_v3['pass'].sum())}/12")
summary
final: 12/12
Loading...

Doce de doce — y, más importante, una traza de auditoría: sabemos cuáles capacidades fueron probadas, cuáles tolerancias sostiene la física y qué no podía hacer el agente hasta que cambió su método. Esa tabla es lo que debería significar «le confío a este agente la caracterización GNSS».

Este es el bucle guiado por la evaluación:

  1. especificación → 2. conjunto de evaluación con verdad de referencia → 3. calificador → 4. corrida → 5. análisis de fallas → apretar la especificación o corregir el agente → de vuelta a 4.

Es el mismo bucle que el desarrollo de modelos del capítulo 3, con el conjunto de evaluación en el papel del conjunto de prueba oculto. Dos advertencias se transfieren con él. Primera, un conjunto de evaluación contra el que usted itera es un conjunto de validación; si lo que está en juego es alto, reserve casos nuevos (¡semillas nuevas!) para una comprobación final, exactamente como la tabla de clasificación reservaba sus datos de prueba. Segunda, la evaluación solo cubre lo que usted puso en ella — nuestro agente está validado para series de una componente con a lo sumo un desplazamiento, y afirmar más sería la extralimitación que el capítulo 7.2 le enseña a consignar con honestidad.

Calificar sin verdad computable

Todo lo anterior calificaba contra una verdad numérica exacta: abs(est - truth) <= tol resuelve cada caso. Muchas de las tareas que usted delegará en realidad no tienen tal número. El ejemplo más claro es aquel hacia el que se dirige este curso: el agente de revisión previa al envío que usted construirá a partir de su propia rúbrica de calidad, y cuyo trabajo es criticar un artículo antes de que usted lo envíe. Una revisión no tiene velocity_mm_yr. Su salida es un juicio, y «¿es buena esta revisión?» es a su vez un juicio.

La maquinaria de evaluación sobrevive, con tres sustituciones.

1. El calificador se vuelve una rúbrica. Descomponga «¿es buena esta revisión?» en comprobaciones binarias que un lector pueda resolver sin preguntarle a usted: nombra la falla de fuga de datos, toda crítica cita una línea, figura o número específicos, propone una corrección concreta para cada falla, no elogia un defecto. Usted construyó exactamente este tipo de rúbrica en la parte (c) de 6.2. Y el truco de la verdad plantada de este cuaderno sigue aplicando: déle al agente de revisión artículos con defectos conocidos y plantados — un fallo de fuga de datos, una cita fabricada, un umbral ajustado sobre el conjunto de prueba — y «encontró el defecto plantado» vuelve a ser una comprobación computable. La verdad de referencia para una tarea subjetiva es algo que usted diseña, no algo que encuentra.

2. Debe medir si la rúbrica se puede aplicar de forma consistente. Una rúbrica solo funciona como calificador si dos evaluadores que la aplican a la misma salida llegan a los mismos veredictos. Eso es medible: haga que dos evaluadores califiquen la misma salida (el intercambio con un compañero de la parte (c) de 6.2 produce exactamente estos datos) y calcule su acuerdo. El acuerdo porcentual crudo no basta — si la mayoría de las comprobaciones se aprueba, dos evaluadores coinciden a menudo por azar —, así que reporte el kappa de Cohen, el acuerdo corregido por azar. Cabe en cinco líneas:

# Two raters apply the same 12-check rubric to one AI-written review
# (1 = criterion met). The 6.2 part (c) partner swap yields exactly this data.
rater_1 = np.array([1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1])
rater_2 = np.array([1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1])


def cohens_kappa(a, b):
    """Two-rater agreement corrected for chance: (p_o - p_e) / (1 - p_e)."""
    a, b = np.asarray(a), np.asarray(b)
    p_o = (a == b).mean()                                        # observed
    p_e = a.mean() * b.mean() + (1 - a.mean()) * (1 - b.mean())  # by chance
    return (p_o - p_e) / (1 - p_e)


print(f"percent agreement: {100 * (rater_1 == rater_2).mean():.0f}%   "
      f"kappa: {cohens_kappa(rater_1, rater_2):.2f}")

# Why percent agreement alone misleads: two lenient raters who pass almost
# everything agree constantly -- by chance.
lenient_1 = np.array([1] * 11 + [0])
lenient_2 = np.array([1] * 12)
print(f"lenient raters:    {100 * (lenient_1 == lenient_2).mean():.0f}%   "
      f"kappa: {cohens_kappa(lenient_1, lenient_2):.2f}")
percent agreement: 83%   kappa: 0.63
lenient raters:    92%   kappa: 0.00

Reporte ambos números. La primera pareja coincide en el 83 % de las comprobaciones y su kappa es 0.63 — consistencia real pero imperfecta. La pareja indulgente coincide en el 92 % de las comprobaciones y su kappa es 0.00: cada pizca de ese acuerdo es lo que producen por azar dos que dicen que sí a todo. Un kappa bajo es un defecto de la rúbrica antes que un defecto de los evaluadores: cada desacuerdo marca un criterio cuya redacción dos lectores resolvieron de manera distinta. Reescriba ese criterio, recalifique, vuelva a medir. Una rúbrica que dos humanos no pueden aplicar de forma consistente no está lista para entregársela a un LLM juez.

Cuando el segundo evaluador sea un modelo (como en la parte (c) de 6.2, y como será la calificación de su propio agente de revisión), añada los controles para los sesgos del juez que midió allí, aplicados en el momento de calificar:

  • Orden. Califique el par como (A, B) y otra vez como (B, A); un veredicto que se voltea con la posición es sesgo de posición, no señal.
  • Verbosidad. Califique cada comprobación de la rúbrica de forma independiente en vez de pedir una impresión global — la extensión infla las calificaciones holísticas mucho más de lo que infla las respuestas por comprobación.
  • Cegamiento. No le diga al juez qué salida vino de su propio agente, y quite todo preámbulo autoidentificatorio antes de calificar.

3. Los veredictos únicos se vuelven tasas de éxito. agent_v2 devuelve el mismo diccionario en cada corrida; un LLM muestreado no. Corra un agente de revisión en vivo cinco veces sobre el mismo artículo y atrapará un defecto plantado en algunas corridas y lo perderá en otras. Así que la unidad de medida por caso cambia de aprobar/reprobar a una tasa de éxito con incertidumbre de muestreo. Abajo están los resultados grabados de un agente de revisión corrido N=5 veces por caso sobre seis análisis breves con defectos plantados — las transcripciones se grabaron una vez y se calificaron con rúbrica, así que esta celda corre sin conexión y en CI como todo lo demás:

# Rubric-scored outcome of each recorded run: 1 = the review named the planted
# defect (for P03, correctly reported that nothing was planted).
recorded_runs = {
    "P01 leakage: scaler fit before split":    [1, 1, 1, 1, 1],
    "P02 threshold tuned on the test split":   [1, 1, 0, 1, 1],
    "P03 no planted flaw (negative case)":     [1, 1, 1, 1, 1],
    "P04 single seed, no spread reported":     [0, 1, 0, 1, 0],
    "P05 axis units off by 10^3 in figure":    [0, 0, 1, 0, 0],
    "P06 fabricated citation in related work": [1, 0, 1, 1, 0],
}

rows = []
for case, outcomes in recorded_runs.items():
    p = np.mean(outcomes)
    se = np.sqrt(p * (1 - p) / len(outcomes))   # binomial standard error
    rows.append({"case": case, "runs": "".join(map(str, outcomes)),
                 "pass_rate": p, "std_err": round(se, 2)})
pd.DataFrame(rows).set_index("case")
Loading...

Lea P04: una evaluación de una sola corrida lo habría declarado aprobado o reprobado según el azar de una moneda — corra una sola vez y despliegue, y su conclusión sobre el agente depende del sorteo. Y los errores estándar dicen qué significa una diferencia: con N=5, una tasa cercana a 0.5 acarrea ±0.22, así que una versión del agente que saca 3/5 donde otra sacó 2/5 en un caso no es evidencia de mejora. Tres consecuencias para la evaluación de su trabajo culminante: reporte tasas, no veredictos; fije N en la especificación antes de correr; y exija un margen mayor que el error estándar antes de creer que un cambio ayudó (o aumente N hasta que las barras de error se separen).

En conjunto — comprobaciones de rúbrica sobre defectos plantados, kappa entre dos evaluadores sobre la rúbrica misma, controles de orden, verbosidad y cegamiento, tasas de éxito sobre N corridas — esta es la maquinaria completa de calificación para el agente de revisión que usted construirá en el arco de lectura. Califica artículos en vez de velocidades, y eso no lo exime en nada de ser evaluado.

Opcional: la misma evaluación contra un modelo en vivo

Todo lo calificado en este capítulo corre sin conexión: el agente simulado y las transcripciones grabadas son la base calificada, y CI las ejecuta sin red. Si tiene el hardware y las ganas, el mismo arnés corre sin cambios contra un modelo real de pesos abiertos — OLMo 2, el modelo completamente abierto de Ai2 (pesos, datos de entrenamiento y código, todo liberado) — servido localmente por Ollama:

ollama pull olmo2               # 7B default: ~4.5 GB disk; ~8 GB RAM to run; CPU works, slowly
export MLGEO_LIVE_EVAL=1        # opt in, then launch Jupyter from the same shell

Ollama expone un punto de acceso compatible con OpenAI en http://localhost:11434/v1, así que el «agente» de abajo es una sola llamada HTTP. Le entregamos al modelo medias de 30 días de la serie (unos 120 números para un registro de 10 años) — que es deliberadamente el montaje de la mala calculadora de 6.1: un motor de texto al que se le da un problema de regresión. Espere violaciones del esquema y velocidades equivocadas. Ese es el punto pedagógico: la protección del esquema registra las salidas malformadas en vez de estrellarse, las tolerancias juzgan los números, y con una temperatura de muestreo distinta de cero usted necesitaría la maquinaria de repeticiones de la sección anterior. Un agente en vivo capaz, en cambio, escribiría y ejecutaría código sobre la serie cruda; montar eso es un ejercicio de escala de proyecto, no una celda de cuaderno.

La celda corre la evaluación en vivo solo cuando usted ha optado por ella con MLGEO_LIVE_EVAL=1 y un servidor local de Ollama tiene descargado un modelo olmo2; si no, imprime una nota y sigue adelante. No puede hacer fallar una compilación: sin la opción activada no hay llamada de red, e incluso una corrida activada que dé error degrada a un mensaje. Nada de esto se califica, y ninguna tarea lo exige.

import json
import os
import urllib.request

OLLAMA = "http://localhost:11434"


def ollama_model(prefix="olmo2"):
    """Exact tag of a pulled model matching `prefix` (e.g. 'olmo2:7b'), or None."""
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=2) as r:
            tags = json.load(r)
        return next((m["name"] for m in tags.get("models", [])
                     if m["name"].startswith(prefix)), None)
    except Exception:
        return None


def ask_olmo(prompt, model):
    body = json.dumps({"model": model, "temperature": 0,
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    req = urllib.request.Request(f"{OLLAMA}/v1/chat/completions", data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=600) as r:
        return json.load(r)["choices"][0]["message"]["content"]


def make_agent_olmo(model):
    def agent_olmo(df):
        """Live agent: the spec plus 30-day means of the series, JSON answer requested."""
        means = df["disp_mm"].groupby(np.arange(len(df)) // 30).mean().round(1)
        prompt = (SPEC_V1
                  + "\nDATA: consecutive 30-day means of disp_mm, in mm "
                    "(bin i covers days 30*i to 30*i + 29):\n"
                  + ", ".join(str(v) for v in means)
                  + "\nAnswer with ONLY the JSON object described under OUTPUT. "
                    "offset_day must be a day index, not a bin index.")
        text = ask_olmo(prompt, model)
        try:
            return json.loads(text[text.index("{"): text.rindex("}") + 1])
        except Exception:
            return {"unparseable_text": text}   # the schema guard scores this
    return agent_olmo


# Opt in explicitly: export MLGEO_LIVE_EVAL=1 before launching Jupyter.
# The env-var gate keeps book builds fast and deterministic even on machines
# where an Ollama server happens to be running.
live_model = ollama_model() if os.environ.get("MLGEO_LIVE_EVAL") == "1" else None
if live_model is None:
    print("Live run disabled (set MLGEO_LIVE_EVAL=1 with a local Ollama server "
          "and `ollama pull olmo2`).")
    print("The offline simulated/recorded path above is the graded baseline.")
else:
    try:
        res_live = run_eval(make_agent_olmo(live_model), vel_tol_fn=vel_tol_v3)
        print(f"{live_model} live: {int(res_live['pass'].sum())}/12 cases pass")
        display(res_live)
    except Exception as e:   # a live server that misbehaves must not kill the notebook
        print(f"Live run against {live_model} failed ({type(e).__name__}: {e}) — skipping.")
Live run disabled (set MLGEO_LIVE_EVAL=1 with a local Ollama server and `ollama pull olmo2`).
The offline simulated/recorded path above is the graded baseline.

Tarea: un conjunto de evaluación para el dominio de su proyecto

Repita los pasos 1 a 5 para una tarea de agente del dominio de su proyecto. Usted elige la tarea; debe ser una que de verdad delegaría. Ejemplos para calibrar el alcance: «marque la llegada de la onda P dentro de ±0.1 s» (seismogram_dataset), «clasifique la litología a partir de una fila de geoquímica y absténgase cuando haya incertidumbre» (geochem_table), «estime el valor b a partir de un catálogo de magnitudes» (gutenberg_richter_magnitudes), «señale los eventos raros en esta serie de sensor» (inject_rare_events).

Entregables, en un solo cuaderno:

  1. Especificación de la tarea — entradas, esquema de salida, tolerancias y cualquier restricción de método obligatoria. Escrita primero, versionada si cambió. Tolerancias derivadas de realizaciones cuando el generador sea suyo, no adivinadas.
  2. Conjunto de evaluación — al menos 10 casos de un generador de mlgeo_synth (o de su propio generador con verdad de referencia documentada), barriendo la dificultad a lo largo de al menos tres ejes, incluidos casos negativos o de «no hay nada ahí» y al menos un caso cerca del límite de detectabilidad.
  3. Calificador — mecánico, corre con una sola llamada a función y devuelve una falla calificada (nunca una excepción) ante una salida del agente faltante o malformada, como en el paso 3. Basado en tolerancias donde la verdad sea computable; si la salida de su tarea es un juicio, use en su lugar comprobaciones de rúbrica sobre defectos plantados y reporte el acuerdo entre dos evaluadores (porcentaje + kappa) sobre al menos un subconjunto («Calificar sin verdad computable»).
  4. Corrida + análisis de fallas — corra su agente (real o simulado), agrupe las fallas y diagnostique cada grupo: ¿limitación del agente, defecto de la especificación o imposibilidad física?
  5. Redacción — media página: qué cubre el conjunto de evaluación, qué deliberadamente no cubre y qué le confiaría ahora al agente hacer sin supervisión.

Rúbrica de calificación (100 puntos):

CriterioPuntosQué los gana
Claridad de la especificación25Esquema de salida y tolerancias computables sin preguntarle a usted; restricciones justificadas
Cobertura del conjunto de evaluación25Los ejes de dificultad abarcan la física; se incluyen negativos; hay un caso en el límite de detectabilidad; la verdad de referencia es exacta
Solidez del calificador20Función pura de (resultado, verdad, especificación); sin juicio a posteriori; maneja salidas del agente faltantes o malformadas
Análisis de fallas20Fallas agrupadas y diagnosticadas, no enumeradas; cada grupo rastreado al agente, a la especificación o a la física
Redacción10Enunciado honesto del alcance; un lector sabe exactamente qué queda sin probar

Cada fila de la rúbrica está demostrada en el ejemplo desarrollado de arriba — el manejo de salidas malformadas que pide la solidez del calificador es el validate_result del paso 3, y la justificación de tolerancias que pide la claridad de la especificación es la medición sobre 50 realizaciones previa a la especificación v3.

Una advertencia nacida de la experiencia: la forma más común de perder puntos es un conjunto de evaluación que su agente aprueba 10/10 al primer intento. Eso no es un buen agente; es un examen fácil.