Módulo 5: ReAct, Self-Consistency y Patrones Avanzados

3. Self-Consistency: Múltiples Caminos, Una Respuesta

Descripción

Self-Consistency es una técnica propuesta por Wang et al. en 2022 que mejora la precisión de los modelos de lenguaje en tareas de razonamiento mediante un principio estadístico simple: si generas múltiples respuestas independientes para el mismo problema y la mayoría coincide, esa respuesta es probablemente correcta.

La intuición es análoga a cómo funcionan los juries en un juicio, los comités de expertos, o incluso el concepto de "sabiduría de la multitud": una sola opinión puede estar sesgada o equivocada, pero el consenso de muchos caminos independientes tiende a ser más robusto.


La Intuición Estadística

Imagina que un modelo tiene un 70% de probabilidad de responder correctamente una pregunta difícil con CoT puro. ¿Qué pasa si preguntamos N veces?

P(al menos 1 correcta con N=5) = 1 - P(todas incorrectas)
P(todas incorrectas) = (0.30)^5 = 0.00243
P(al menos 1 correcta) ≈ 99.76%

PERO: con majority vote (3 de 5), la distribución de aciertos mejora más:
- Con p=0.7, P(mayoría correcta con N=5) ≈ 83.69%
- Con p=0.7, P(mayoría correcta con N=9) ≈ 90.12%
- Con p=0.7, P(mayoría correcta con N=15) ≈ 95.24%

La clave: la variación en el razonamiento (temperature > 0) hace que el camino incorrecto tome diferentes rutas erróneas, mientras que el camino correcto tiende a converger en la misma respuesta.


Implementación Base

from openai import OpenAI
from collections import Counter
import re
from typing import Optional

client = OpenAI()

def extraer_respuesta_numerica(texto: str) -> Optional[str]:
    """
    Extrae la respuesta final numérica de un texto de razonamiento CoT.
    Maneja múltiples formatos: 'Answer: 42', '= 42', 'the answer is 42', etc.
    """
    texto = texto.strip()
    
    # Patrones comunes de respuesta final
    patrones = [
        r'(?:respuesta|answer|resultado|result|final)[\s:=]+(-?\d+\.?\d*)',
        r'(?:por lo tanto|therefore|entonces|thus|so)[,\s]+(?:la respuesta es\s+)?(-?\d+\.?\d*)',
        r'=\s*(-?\d+\.?\d*)\s*$',  # Al final: = 42
        r'\*\*(-?\d+\.?\d*)\*\*',  # Negritas: **42**
    ]
    
    for patron in patrones:
        match = re.search(patron, texto, re.IGNORECASE | re.MULTILINE)
        if match:
            return match.group(1)
    
    # Fallback: último número del texto
    numeros = re.findall(r'-?\d+\.?\d*', texto)
    if numeros:
        return numeros[-1]
    
    # Fallback final: últimas 30 chars del texto (para respuestas no numéricas)
    return texto.strip()[-30:].strip()

def extraer_respuesta_categorica(texto: str, categorias: list[str]) -> str:
    """
    Extrae una respuesta categórica buscando las categorías en el texto.
    
    Args:
        texto: Texto de respuesta del modelo
        categorias: Lista de categorías válidas (ej: ["POSITIVO", "NEGATIVO", "NEUTRAL"])
    """
    texto_upper = texto.upper()
    for cat in categorias:
        if cat.upper() in texto_upper:
            return cat
    return texto.strip()[-30:]  # Fallback

def self_consistency(
    problema: str,
    n: int = 5,
    temperatura: float = 0.7,
    max_tokens: int = 500,
    extraer_fn = None
) -> dict:
    """
    Implementa Self-Consistency: genera N respuestas y vota por mayoría.
    
    Args:
        problema: El problema o pregunta a resolver
        n: Número de muestras (típicamente 3-10)
        temperatura: Temperature para variación (0.5-1.0 recomendado)
        max_tokens: Máximo tokens por respuesta
        extraer_fn: Función para extraer la respuesta final del texto
    
    Returns:
        dict con respuesta ganadora, todas las respuestas, y estadísticas
    """
    if extraer_fn is None:
        extraer_fn = extraer_respuesta_numerica
    
    prompt_cot = f"""{problema}

Piensa paso a paso y muestra todo tu razonamiento antes de dar la respuesta final.
Al final, escribe explícitamente: "Respuesta: [tu respuesta]"
"""
    
    respuestas_raw = []
    respuestas_extraidas = []
    
    for i in range(n):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt_cot}],
            temperature=temperatura,
            max_tokens=max_tokens
        )
        raw = response.choices[0].message.content
        extraida = extraer_fn(raw)
        
        respuestas_raw.append(raw)
        respuestas_extraidas.append(extraida)
    
    # Majority vote
    conteo = Counter(respuestas_extraidas)
    respuesta_ganadora, votos = conteo.most_common(1)[0]
    
    return {
        "respuesta": respuesta_ganadora,
        "votos": votos,
        "total": n,
        "confianza": votos / n,
        "distribucion": dict(conteo),
        "respuestas_raw": respuestas_raw,
        "todas_respuestas": respuestas_extraidas
    }

Implementación Avanzada con Normalización

def normalizar_respuesta(texto: str) -> str:
    """
    Normaliza respuestas para que el voting sea más robusto.
    Maneja casos como "42.0" vs "42", "42,000" vs "42000", etc.
    """
    texto = texto.strip()
    
    # Remover puntuación final
    texto = texto.rstrip('.,;:')
    
    # Normalizar números: remover comas de miles
    texto = texto.replace(',', '')
    
    # Normalizar números decimales: "42.0" → "42"
    try:
        num = float(texto)
        if num == int(num):
            return str(int(num))
        return str(round(num, 4))
    except ValueError:
        pass
    
    # Para respuestas categóricas: uppercase y strip
    return texto.upper().strip()

def self_consistency_robusto(
    problema: str,
    n: int = 5,
    temperatura: float = 0.7,
    normalizar: bool = True
) -> dict:
    """
    Self-Consistency con normalización de respuestas para voting más robusto.
    """
    resultado = self_consistency(problema, n=n, temperatura=temperatura)
    
    if normalizar:
        # Re-normalizar todas las respuestas y re-votar
        respuestas_norm = [normalizar_respuesta(r) for r in resultado["todas_respuestas"]]
        conteo_norm = Counter(respuestas_norm)
        ganadora_norm, votos_norm = conteo_norm.most_common(1)[0]
        
        resultado["respuesta_normalizada"] = ganadora_norm
        resultado["distribucion_normalizada"] = dict(conteo_norm)
        resultado["confianza_normalizada"] = votos_norm / n
    
    return resultado

# Ejemplo de uso:
if __name__ == "__main__":
    problema = "En una tienda, un artículo cuesta $80 después de un descuento del 20%. ¿Cuál era el precio original?"
    
    resultado = self_consistency_robusto(problema, n=5)
    print(f"Respuesta: {resultado['respuesta']}")
    print(f"Distribución: {resultado['distribucion']}")
    print(f"Confianza: {resultado['confianza']:.0%}")
    print(f"¿Consenso claro? {resultado['confianza'] >= 0.6}")

Variantes de Self-Consistency

Variante 1: Self-Consistency para Clasificación

def self_consistency_clasificacion(
    texto: str,
    categorias: list[str],
    n: int = 5
) -> dict:
    """
    Self-Consistency para tareas de clasificación.
    Útil cuando la categorización es ambigua.
    """
    cats_str = ", ".join(categorias)
    prompt = f"""Clasifica el siguiente texto en una de estas categorías: {cats_str}

Texto: {texto}

Primero analiza el texto, luego clasifica. Responde con exactamente una de las categorías.
"""
    
    def extraer_categoria(respuesta: str) -> str:
        return extraer_respuesta_categorica(respuesta, categorias)
    
    resultado = self_consistency(
        prompt,
        n=n,
        temperatura=0.5,  # Menos temperatura para clasificación
        extraer_fn=extraer_categoria
    )
    
    return resultado

# Ejemplo:
reviews = [
    "El producto llegó bien pero el servicio al cliente fue horrible.",  # Ambiguo: POSITIVO/NEGATIVO
    "Absolutamente increíble, lo recomiendo a todos.",  # Claro: POSITIVO
    "Podría ser mejor, pero tampoco es tan malo."  # Ambiguo: NEUTRAL
]

for review in reviews:
    resultado = self_consistency_clasificacion(
        review,
        ["POSITIVO", "NEGATIVO", "NEUTRAL"],
        n=5
    )
    print(f"Review: {review[:50]}...")
    print(f"Clasificación: {resultado['respuesta']} (confianza: {resultado['confianza']:.0%})")
    print(f"Distribución: {resultado['distribucion']}\n")

Variante 2: Self-Consistency con Pesos

def self_consistency_ponderado(
    problema: str,
    n: int = 5
) -> dict:
    """
    En lugar de majority vote simple, asignar pesos según la longitud
    y coherencia del razonamiento.
    """
    respuestas_con_metadata = []
    
    for _ in range(n):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"{problema}\nPiensa paso a paso. Respuesta: [número]"}],
            temperature=0.7,
            max_tokens=500,
            logprobs=True  # Obtener log-probabilidades para pesos
        )
        msg = response.choices[0].message
        raw = msg.content
        
        # Extraer respuesta
        respuesta = extraer_respuesta_numerica(raw)
        
        # Calcular peso basado en longitud del razonamiento
        # (razonamientos más largos y detallados suelen ser más confiables)
        peso = min(len(raw.split()) / 100, 2.0)  # Normalizar, máx peso 2x
        
        respuestas_con_metadata.append({
            "respuesta": respuesta,
            "peso": peso,
            "longitud": len(raw.split())
        })
    
    # Weighted voting
    votos_ponderados = {}
    for item in respuestas_con_metadata:
        resp = item["respuesta"]
        votos_ponderados[resp] = votos_ponderados.get(resp, 0) + item["peso"]
    
    ganadora = max(votos_ponderados, key=votos_ponderados.get)
    
    return {
        "respuesta": ganadora,
        "votos_ponderados": votos_ponderados,
        "detalle": respuestas_con_metadata
    }

Variante 3: Self-Consistency Adaptativo (Stop Temprano)

def self_consistency_adaptativo(
    problema: str,
    n_min: int = 3,
    n_max: int = 10,
    umbral_confianza: float = 0.8
) -> dict:
    """
    Para cuando la respuesta ya es muy clara (alta confianza),
    deja de generar más muestras para ahorrar tokens.
    """
    respuestas_extraidas = []
    
    for i in range(n_max):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"{problema}\nPiensa paso a paso. Respuesta: [tu respuesta]"}],
            temperature=0.7,
            max_tokens=400
        )
        raw = response.choices[0].message.content
        respuestas_extraidas.append(extraer_respuesta_numerica(raw))
        
        # Solo evaluar después del mínimo
        if i + 1 >= n_min:
            conteo = Counter(respuestas_extraidas)
            ganadora, votos = conteo.most_common(1)[0]
            confianza = votos / (i + 1)
            
            if confianza >= umbral_confianza:
                return {
                    "respuesta": ganadora,
                    "confianza": confianza,
                    "n_usadas": i + 1,
                    "stop_temprano": True,
                    "distribucion": dict(conteo)
                }
    
    # Si no llegamos al umbral, retornar la mejor respuesta
    conteo = Counter(respuestas_extraidas)
    ganadora, votos = conteo.most_common(1)[0]
    return {
        "respuesta": ganadora,
        "confianza": votos / n_max,
        "n_usadas": n_max,
        "stop_temprano": False,
        "distribucion": dict(conteo)
    }

# Benchmark de ahorros:
preguntas_simples = ["¿Cuánto es 8 * 9?", "¿Cuánto es 15 + 27?"]
preguntas_dificiles = ["Si el radio de un círculo aumenta 50%, ¿en qué porcentaje aumenta el área?"]

for pregunta in preguntas_simples + preguntas_dificiles:
    r = self_consistency_adaptativo(pregunta)
    print(f"Pregunta: {pregunta[:50]}...")
    print(f"  Respuesta: {r['respuesta']}, Confianza: {r['confianza']:.0%}, "
          f"Llamadas: {r['n_usadas']}, Stop temprano: {r['stop_temprano']}")

Cuándo Usar Self-Consistency

Tareas donde brilla

Tipo de tareaMejora típicaRecomendado N
Matemáticas word problems+8-15%5-7
Razonamiento lógico+5-12%3-5
Clasificación ambigua+3-8%3-5
Comprensión lectora+3-6%3-5
Código/debugging+5-10%5

Tareas donde NO ayuda

  • Tareas creativas: Escribir un poema — no tiene "respuesta correcta" única
  • Tareas de formato: Traducción — la majority vote puede mezclar idiomas
  • Cuando los datos son escasos: Si el modelo no sabe la respuesta, N respuestas incorrectas siguen siendo incorrectas
  • Conversaciones: En diálogos multi-turn, el contexto importa más que la consistencia

Trade-offs: Costo vs. Accuracy

def analizar_tradeoffs(problema: str, max_n: int = 10) -> list[dict]:
    """
    Analiza el trade-off entre N muestras y confianza.
    """
    historial = []
    todas_respuestas = []
    
    for i in range(1, max_n + 1):
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"{problema}\nPiensa paso a paso. Respuesta: [número]"}],
            temperature=0.7,
            max_tokens=300
        )
        todas_respuestas.append(extraer_respuesta_numerica(response.choices[0].message.content))
        
        conteo = Counter(todas_respuestas)
        ganadora, votos = conteo.most_common(1)[0]
        
        historial.append({
            "n": i,
            "respuesta": ganadora,
            "confianza": votos / i,
            "costo_relativo": i  # Simplificación: cada llamada cuesta lo mismo
        })
    
    return historial

# Visualizar:
# import matplotlib.pyplot as plt  # Si tienes matplotlib
# for punto in analizar_tradeoffs("Si un tren viaja a 120 km/h, ¿cuánto tarda en recorrer 300 km?"):
#     print(f"N={punto['n']}: Respuesta={punto['respuesta']}, Confianza={punto['confianza']:.0%}")

Tabla de referencia

NCostoAccuracy típica (math)¿Vale la pena?
11xBase (70-80%)Baseline
33x+5-8%Sí, si accuracy importa
55x+8-12%Sí, para problemas críticos
77x+10-13%Solo si budget lo permite
1010x+12-15%Rendimientos decrecientes
2020x+13-16%Raramente justificado

El punto de inflexión suele estar en N=5: buen balance entre mejora y costo.


Self-Consistency con Anthropic

import anthropic

client_anthropic = anthropic.Anthropic()

def self_consistency_claude(
    problema: str,
    n: int = 5,
    temperatura: float = 0.7
) -> dict:
    """
    Self-Consistency usando Claude (Anthropic).
    """
    prompt = f"""{problema}

Analiza el problema paso a paso y al final escribe "Respuesta: [tu respuesta]"."""
    
    respuestas_extraidas = []
    respuestas_raw = []
    
    for _ in range(n):
        message = client_anthropic.messages.create(
            model="claude-3-5-haiku-20241022",
            max_tokens=500,
            temperature=temperatura,
            messages=[{"role": "user", "content": prompt}]
        )
        raw = message.content[0].text
        respuestas_raw.append(raw)
        respuestas_extraidas.append(extraer_respuesta_numerica(raw))
    
    conteo = Counter(respuestas_extraidas)
    ganadora, votos = conteo.most_common(1)[0]
    
    return {
        "respuesta": ganadora,
        "confianza": votos / n,
        "distribucion": dict(conteo),
        "modelo": "claude-3-5-haiku-20241022"
    }

Benchmark Real: Self-Consistency vs CoT Single

import json
import time

# Conjunto de prueba con respuestas conocidas
BENCHMARK = [
    {
        "problema": "Un comerciante compra 50 kg de manzanas a $2/kg y las vende a $3/kg. Si se pudren 10 kg antes de venderlos, ¿cuál es su ganancia o pérdida?",
        "respuesta_correcta": "20",  # (40 * 3) - (50 * 2) = 120 - 100 = 20 USD
        "tipo": "word_problem"
    },
    {
        "problema": "¿Cuál es el 15% de 840?",
        "respuesta_correcta": "126",
        "tipo": "porcentaje"
    },
    {
        "problema": "Si 5 máquinas hacen 5 piezas en 5 minutos, ¿cuántas máquinas necesitas para hacer 100 piezas en 100 minutos?",
        "respuesta_correcta": "5",  # Respuesta contraintuitiva
        "tipo": "logica"
    },
    {
        "problema": "Un bote tiene capacidad para 10 personas. ¿Cuántos viajes necesita para cruzar a 45 personas?",
        "respuesta_correcta": "5",  # 45/10 redondeado arriba
        "tipo": "division"
    }
]

def evaluar_respuesta(pred: str, correcta: str) -> bool:
    """Compara respuestas normalizando."""
    try:
        return abs(float(normalizar_respuesta(pred)) - float(normalizar_respuesta(correcta))) < 0.01
    except ValueError:
        return pred.strip() == correcta.strip()

def run_benchmark(n_muestras: int = 5) -> dict:
    """Ejecuta el benchmark comparando CoT simple vs Self-Consistency."""
    resultados_cot = []
    resultados_sc = []
    
    for item in BENCHMARK:
        # CoT single
        cot_resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": f"{item['problema']}\nPiensa paso a paso. Respuesta: [número]"}],
            temperature=0
        ).choices[0].message.content
        cot_pred = extraer_respuesta_numerica(cot_resp)
        cot_correcto = evaluar_respuesta(cot_pred, item["respuesta_correcta"])
        resultados_cot.append(cot_correcto)
        
        # Self-Consistency
        sc_result = self_consistency(item["problema"], n=n_muestras)
        sc_correcto = evaluar_respuesta(sc_result["respuesta"], item["respuesta_correcta"])
        resultados_sc.append(sc_correcto)
        
        print(f"\nProblema: {item['problema'][:60]}...")
        print(f"  CoT: {cot_pred}{'✓' if cot_correcto else '✗'}")
        print(f"  SC: {sc_result['respuesta']} (confianza {sc_result['confianza']:.0%}) → {'✓' if sc_correcto else '✗'}")
    
    accuracy_cot = sum(resultados_cot) / len(resultados_cot)
    accuracy_sc = sum(resultados_sc) / len(resultados_sc)
    
    print(f"\n=== RESULTADOS ===")
    print(f"CoT simple: {accuracy_cot:.0%}")
    print(f"Self-Consistency N={n_muestras}: {accuracy_sc:.0%}")
    print(f"Mejora: +{(accuracy_sc - accuracy_cot) * 100:.1f}%")
    
    return {
        "accuracy_cot": accuracy_cot,
        "accuracy_sc": accuracy_sc,
        "mejora": accuracy_sc - accuracy_cot
    }

Troubleshooting

Problema 1: Respuestas en formatos distintos impiden el voting correcto

Síntoma: "42" y "42.0" y "42,0" cuentan como respuestas diferentes aunque sean iguales.

Solución:

def normalizar_agresivo(texto: str) -> str:
    """Normalización más agresiva para voting."""
    # Remover todo excepto dígitos y punto decimal
    solo_numeros = re.sub(r'[^\d.-]', '', texto.split()[-1] if texto.split() else texto)
    try:
        num = float(solo_numeros)
        # Redondear a 2 decimales para evitar diferencias de precisión
        num = round(num, 2)
        return str(int(num)) if num == int(num) else str(num)
    except ValueError:
        return texto.upper().strip()

Problema 2: Empate entre respuestas (ej: 2 vs 2 con N=4)

Síntoma: Counter.most_common(1) retorna una de las empatadas arbitrariamente.

Solución:

def resolver_empate(conteo: Counter, respuestas_raw: list[str]) -> str:
    """
    Estrategias para resolver empates:
    1. Elegir la respuesta más larga (más razonamiento)
    2. Hacer una llamada de desempate
    3. Retornar la más "conservadora" (menor número en finanzas, por ejemplo)
    """
    max_votos = conteo.most_common(1)[0][1]
    empatadas = [r for r, v in conteo.items() if v == max_votos]
    
    if len(empatadas) == 1:
        return empatadas[0]
    
    # Estrategia: desempate con una llamada adicional
    candidates_str = "\n".join([f"- {r}" for r in empatadas])
    desempate = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"Las siguientes respuestas empatan con el mismo número de votos:\n{candidates_str}\n\n¿Cuál es matemáticamente más probable que sea correcta? Responde solo con la respuesta correcta."
        }],
        temperature=0
    )
    return desempate.choices[0].message.content.strip()

Problema 3: Costo alto para N grande

Síntoma: N=10 es demasiado caro para volumen alto de consultas.

Solución: Usar self_consistency_adaptativo (ver arriba) o un threshold dinámico:

def self_consistency_presupuesto(
    problema: str,
    presupuesto_usd: float = 0.001  # $0.001 por problema
) -> dict:
    """
    Self-Consistency con presupuesto fijo.
    Estima N según el presupuesto disponible.
    """
    # Estimación: ~500 tokens por llamada, gpt-4o-mini ~$0.000195/llamada
    costo_por_llamada = 0.000195
    n_max = max(1, int(presupuesto_usd / costo_por_llamada))
    n_efectivo = min(n_max, 10)  # Cap en 10
    
    return self_consistency(problema, n=n_efectivo)

Ejercicios

Ejercicio 1: Implementar extracción de respuesta robusta

Mejora la función extraer_respuesta_numerica para que maneje todos estos formatos:

"The answer is 42"
"42"
"42.0"
"= 42"
"Por lo tanto, la respuesta es **42**"
"Ganancia: $42 USD"
"Conclusión: 42 elementos"
"La respuesta es cuarenta y dos (42)"
Ver solución
import re

def extraer_respuesta_v2(texto: str) -> str:
    """Extracción robusta de respuesta numérica."""
    texto = texto.strip()
    
    # Patrón 1: "Answer: 42" o "Respuesta: 42"
    m = re.search(r'(?:answer|respuesta|resultado|ganancia|pérdida|total|final)[:\s]+\$?(-?\d+\.?\d*)', texto, re.IGNORECASE)
    if m:
        return m.group(1)
    
    # Patrón 2: "**42**" (negritas markdown)
    m = re.search(r'\*\*\$?(-?\d+\.?\d*)\*\*', texto)
    if m:
        return m.group(1)
    
    # Patrón 3: "= 42" al final de línea
    m = re.search(r'=\s*\$?(-?\d+\.?\d*)\s*(?:USD|EUR|€|\$)?$', texto, re.MULTILINE)
    if m:
        return m.group(1)
    
    # Patrón 4: número entre paréntesis con contexto "cuarenta y dos (42)"
    m = re.search(r'\((\d+)\)', texto)
    if m:
        return m.group(1)
    
    # Patrón 5: "42 elementos", "42 USD", etc.
    m = re.search(r'\b(-?\d+\.?\d*)\s*(?:elementos?|USD|EUR|€|\$|kg|km|años?|días?|meses?)?\s*$', texto)
    if m:
        return m.group(1)
    
    # Fallback: último número del texto
    numeros = re.findall(r'-?\d+\.?\d*', texto)
    return numeros[-1] if numeros else texto[-20:]

# Test:
casos = [
    "The answer is 42",
    "42",
    "42.0",
    "= 42",
    "Por lo tanto, la respuesta es **42**",
    "Ganancia: $42 USD",
    "Conclusión: 42 elementos",
    "La respuesta es cuarenta y dos (42)"
]

for caso in casos:
    extraido = extraer_respuesta_v2(caso)
    print(f"'{caso[:40]}' → '{extraido}'")

Ejercicio 2: Self-Consistency para múltiple choice

Implementa Self-Consistency para preguntas de opción múltiple (A, B, C, D). El voting debe buscar la letra, no el número.

Ver solución
def self_consistency_multiple_choice(
    pregunta: str,
    opciones: dict,  # {"A": "opción a", "B": "opción b", ...}
    n: int = 5
) -> dict:
    """Self-Consistency para opción múltiple."""
    opciones_str = "\n".join([f"{k}) {v}" for k, v in opciones.items()])
    
    prompt = f"""{pregunta}

Opciones:
{opciones_str}

Analiza cada opción y razona por qué es o no correcta. Al final escribe SOLO la letra de la respuesta correcta."""
    
    def extraer_letra(texto: str) -> str:
        # Buscar letra al final o la más reciente
        letras = re.findall(r'\b([A-D])\b', texto.upper())
        return letras[-1] if letras else "?"
    
    resultado = self_consistency(
        prompt,
        n=n,
        temperatura=0.5,  # Menos variación para MC
        extraer_fn=extraer_letra
    )
    
    respuesta_letra = resultado["respuesta"]
    respuesta_texto = opciones.get(respuesta_letra, "No encontrada")
    
    return {
        **resultado,
        "respuesta_completa": f"{respuesta_letra}) {respuesta_texto}"
    }

# Test:
pregunta = "¿Cuál es la capital de Brasil?"
opciones = {"A": "São Paulo", "B": "Río de Janeiro", "C": "Brasília", "D": "Buenos Aires"}
r = self_consistency_multiple_choice(pregunta, opciones, n=5)
print(f"Respuesta: {r['respuesta_completa']} (confianza: {r['confianza']:.0%})")

Ejercicio 3: Análisis de confianza

Ejecuta Self-Consistency con N=10 sobre 5 preguntas de distinta dificultad. Grafica la relación entre confianza (mayoría/total) y exactitud de la respuesta. ¿Hay correlación?

Ver solución
preguntas_con_respuesta = [
    ("¿Cuánto es 7 * 8?", "56"),      # Fácil, confianza alta
    ("¿Cuánto es 23 * 17?", "391"),   # Media
    ("Un tren va a 120km/h, ¿cuánto tarda en 300km?", "2.5"),  # Word problem
    ("Si el radio aumenta 50%, ¿en qué % aumenta el área?", "125"),  # Conceptual
    ("¿Cuántas rotaciones hace una rueda de 2m de circunferencia en 1km?", "500"),  # Cálculo
]

analisis = []
for pregunta, correcta in preguntas_con_respuesta:
    r = self_consistency(pregunta, n=10)
    es_correcta = evaluar_respuesta(r["respuesta"], correcta)
    analisis.append({
        "pregunta": pregunta[:40],
        "respuesta": r["respuesta"],
        "correcta": correcta,
        "confianza": r["confianza"],
        "acertado": es_correcta
    })
    print(f"Pregunta: {pregunta[:40]}...")
    print(f"  Pred: {r['respuesta']}, Correcta: {correcta}")
    print(f"  Confianza: {r['confianza']:.0%}, Acertado: {'✓' if es_correcta else '✗'}")

# Calcular correlación (simplificada):
# Alta confianza (>0.6) + Acertado: True positivo
# Alta confianza + No acertado: Falso positivo
# Baja confianza + Acertado: Falso negativo
# Baja confianza + No acertado: Verdadero negativo

Ejercicio 4: Comparar temperaturas

Experimenta con diferentes valores de temperatura (0.3, 0.5, 0.7, 1.0) para N=5. ¿Qué temperatura produce el mejor balance entre diversidad de razonamiento y convergencia a la respuesta correcta?

Ver solución
problema = "Un comerciante vende un artículo con 25% de ganancia. Si el precio de costo es $80, ¿cuál es el precio de venta?"

temperaturas = [0.3, 0.5, 0.7, 1.0]
for temp in temperaturas:
    resultados = []
    for _ in range(5):  # 5 runs para promediar
        r = self_consistency(problema, n=5, temperatura=temp)
        resultados.append(r["confianza"])
    
    avg_confianza = sum(resultados) / len(resultados)
    # Respuesta correcta: 80 * 1.25 = 100
    print(f"Temperature={temp}: confianza promedio={avg_confianza:.0%}")

# Resultado esperado:
# Temperature=0.3: confianza alta (~90%), poca diversidad
# Temperature=0.7: balance (~80%), buena diversidad
# Temperature=1.0: confianza baja (~65%), mucha diversidad

Resumen

  • Self-Consistency: Generar N respuestas independientes con temperatura > 0, votar por mayoría
  • Mejora empírica: +5-15% en precisión para math/logic/reasoning vs CoT single
  • Temperature: 0.5-0.8 es el rango ideal para balance diversidad/convergencia
  • N óptimo: 5 para la mayoría de casos; stop temprano si confianza > 80%
  • Normalización: Crítica para que el voting funcione (42 vs 42.0 vs "42")
  • Cuándo usar: Math word problems, razonamiento lógico, clasificación ambigua
  • Cuándo NO usar: Tareas creativas, conversaciones, cuando el modelo definitivamente no sabe la respuesta

Recursos adicionales

  1. Self-Consistency Improves Chain of Thought Reasoning (Wang et al., 2022) - Paper original
  2. Large Language Models are Zero-Shot Reasoners (Kojima et al., 2022) - Relacionado con CoT
  3. OpenAI API - Temperature parameter
  4. Python Counter documentation
  5. Prompt Engineering Guide - Self-Consistency
  6. Benchmark datasets para math: GSM8K