Módulo 6: Decision Matrix para AI Engineers

Cápsula 03: Pesos, Scoring y Metodología de la Matriz

🎯 Objetivo de la cápsula

Dominar la mecánica cuantitativa de la matriz de decisión: cómo asignar pesos que reflejen prioridades reales, cómo puntuar proveedores de forma consistente y cómo normalizar resultados para que la comparación sea justa.

Al finalizar esta cápsula:

  • ✅ Asignarás pesos usando técnicas que eliminan sesgo
  • ✅ Construirás rúbricas de scoring objetivas por criterio
  • ✅ Normalizarás puntuaciones para comparar dimensiones diferentes
  • ✅ Implementarás la fórmula completa de scoring en Python

Tiempo estimado: 25-35 minutos


Descripción de la cápsula

En la cápsula anterior definiste QUÉ evaluar. Ahora necesitas definir CÓMO evaluarlo. La diferencia entre una decisión sólida y una decisión disfrazada de análisis está en la metodología de scoring. Si asignas pesos arbitrarios o puntúas "a ojo", tu matriz sofisticada produce exactamente el mismo resultado que elegir al azar — pero con la falsa confianza de un número.

Esta cápsula te enseña tres técnicas profesionales para asignar pesos (distribución fija, comparación por pares y stack ranking), dos escalas de scoring con rúbricas concretas para cada nivel, y el proceso de normalización que permite comparar manzanas con naranjas (latencia en milisegundos vs costo en dólares vs complejidad operativa).

Al final, tendrás un ScoringEngine completo en Python que puedes alimentar con tus criterios y proveedores para obtener un ranking cuantitativo reproducible. No es magia — es disciplina.


Por qué los pesos importan más que los scores

Considera este ejemplo:

# Mismo proveedor, diferentes pesos = diferente ganador

provider_scores = {
    "pinecone": {"latency": 0.9, "cost": 0.5, "ops_simplicity": 1.0},
    "qdrant_self": {"latency": 0.8, "cost": 0.9, "ops_simplicity": 0.4},
}

# Equipo A: startup sin DevOps, velocidad importa
weights_startup = {"latency": 3, "cost": 4, "ops_simplicity": 5}

# Equipo B: empresa con SRE, costo domina
weights_enterprise = {"latency": 5, "cost": 5, "ops_simplicity": 2}

def weighted_score(scores: dict, weights: dict) -> float:
    total = sum(weights[k] * scores[k] for k in weights)
    max_possible = sum(weights.values())
    return round((total / max_possible) * 100, 1)

print("=== Equipo A (startup sin DevOps) ===")
for name, scores in provider_scores.items():
    print(f"  {name}: {weighted_score(scores, weights_startup)}%")

print("\n=== Equipo B (enterprise con SRE) ===")
for name, scores in provider_scores.items():
    print(f"  {name}: {weighted_score(scores, weights_enterprise)}%")
=== Equipo A (startup sin DevOps) ===
  pinecone: 80.8%
  qdrant_self: 67.5%

=== Equipo B (enterprise con SRE) ===
  pinecone: 75.0%
  qdrant_self: 75.8%

Mismo dato, diferente conclusión. Los pesos determinan el resultado más que los scores individuales. Por eso necesitas un proceso riguroso para asignarlos.


Técnica 1: Distribución fija de puntos

Cada stakeholder recibe un presupuesto fijo de puntos (por ejemplo, 100) y los distribuye entre los criterios. Esto fuerza trade-offs reales:

def fixed_distribution(criteria: list[str], total_points: int = 100) -> dict:
    """
    Simula la distribución fija de puntos.
    En la práctica, cada stakeholder llena esto individualmente.
    """
    print(f"Distribuye {total_points} puntos entre {len(criteria)} criterios.")
    print("Regla: la suma DEBE ser exactamente {total_points}.")
    print("Criterios:", ", ".join(criteria))

    return {}  # En la práctica, se llena manualmente


def aggregate_distributions(distributions: list[dict]) -> dict:
    """Promedia las distribuciones de múltiples stakeholders."""
    all_keys = set()
    for d in distributions:
        all_keys.update(d.keys())

    aggregated = {}
    for key in all_keys:
        values = [d.get(key, 0) for d in distributions]
        aggregated[key] = round(sum(values) / len(values), 1)

    return aggregated


# Ejemplo: 3 stakeholders distribuyen 100 puntos
cto_weights = {
    "latency": 25, "scale": 25, "cost": 15,
    "ops_simplicity": 10, "sdk_quality": 15, "compliance": 10
}
pm_weights = {
    "latency": 15, "scale": 10, "cost": 30,
    "ops_simplicity": 20, "sdk_quality": 5, "compliance": 20
}
dev_weights = {
    "latency": 20, "scale": 15, "cost": 10,
    "ops_simplicity": 15, "sdk_quality": 30, "compliance": 10
}

# Validación: cada distribución debe sumar 100
for name, weights in [("CTO", cto_weights), ("PM", pm_weights), ("Dev", dev_weights)]:
    total = sum(weights.values())
    status = "✅" if total == 100 else f"❌ (suma {total})"
    print(f"{name}: {status}")

# Agregación
final_weights = aggregate_distributions([cto_weights, pm_weights, dev_weights])
print("\nPesos finales (promedio):")
for criterion, weight in sorted(final_weights.items(), key=lambda x: -x[1]):
    print(f"  {criterion}: {weight}")
CTO: ✅
PM: ✅
Dev: ✅

Pesos finales (promedio):
  latency: 20.0
  cost: 18.3
  sdk_quality: 16.7
  ops_simplicity: 15.0
  scale: 16.7
  compliance: 13.3

Ventaja: Fuerza trade-offs reales (no puedes decir "todo es importante"). Desventaja: No captura la intensidad relativa entre pares de criterios.


Técnica 2: Comparación por pares (Pairwise)

Compara cada criterio contra cada otro criterio y decide cuál es más importante. El conteo de "victorias" determina el peso:

from itertools import combinations


def pairwise_comparison(criteria: list[str], preferences: dict[tuple, str]) -> dict:
    """
    Calcula pesos basados en comparación por pares.

    Args:
        criteria: Lista de nombres de criterios
        preferences: Dict de (criterio_a, criterio_b) -> ganador
    """
    wins = {c: 0 for c in criteria}

    for (a, b), winner in preferences.items():
        wins[winner] += 1

    total_wins = sum(wins.values())
    if total_wins == 0:
        return {c: 1.0 / len(criteria) for c in criteria}

    weights = {c: round(w / total_wins, 3) for c, w in wins.items()}
    return weights


criteria = ["latency", "cost", "ops", "sdk", "scale", "compliance"]

# Para cada par, ¿cuál es más importante para TU proyecto?
preferences = {
    ("latency", "cost"): "latency",
    ("latency", "ops"): "latency",
    ("latency", "sdk"): "latency",
    ("latency", "scale"): "scale",
    ("latency", "compliance"): "latency",
    ("cost", "ops"): "cost",
    ("cost", "sdk"): "cost",
    ("cost", "scale"): "scale",
    ("cost", "compliance"): "compliance",
    ("ops", "sdk"): "ops",
    ("ops", "scale"): "scale",
    ("ops", "compliance"): "compliance",
    ("sdk", "scale"): "scale",
    ("sdk", "compliance"): "sdk",
    ("scale", "compliance"): "scale",
}

weights = pairwise_comparison(criteria, preferences)
print("Pesos por comparación por pares:")
for criterion, weight in sorted(weights.items(), key=lambda x: -x[1]):
    print(f"  {criterion}: {weight:.1%}")
Pesos por comparación por pares:
  scale: 26.7%
  latency: 26.7%
  compliance: 20.0%
  cost: 13.3%
  ops: 6.7%
  sdk: 6.7%

Ventaja: Más intuitivo ("¿qué importa más, A o B?") y produce rankings claros. Desventaja: Con N criterios, necesitas N×(N-1)/2 comparaciones (15 para 6 criterios).


Técnica 3: Stack ranking

La más simple: ordena todos los criterios de más a menos importante y asigna pesos decrecientes automáticamente:

def stack_rank_weights(ranked_criteria: list[str], method: str = "linear") -> dict:
    """
    Asigna pesos basados en ranking ordinal.

    Args:
        ranked_criteria: Lista ordenada (más importante primero)
        method: 'linear' (N, N-1, ..., 1) o 'geometric' (2^N, 2^(N-1), ...)
    """
    n = len(ranked_criteria)

    if method == "linear":
        raw_weights = {c: n - i for i, c in enumerate(ranked_criteria)}
    elif method == "geometric":
        raw_weights = {c: 2 ** (n - 1 - i) for i, c in enumerate(ranked_criteria)}
    else:
        raise ValueError(f"Método desconocido: {method}")

    total = sum(raw_weights.values())
    normalized = {c: round(w / total, 3) for c, w in raw_weights.items()}
    return normalized


my_ranking = ["scale", "latency", "cost", "ops", "compliance", "sdk"]

print("Stack rank - Linear:")
linear = stack_rank_weights(my_ranking, "linear")
for c, w in linear.items():
    print(f"  {c}: {w:.1%}")

print("\nStack rank - Geometric:")
geometric = stack_rank_weights(my_ranking, "geometric")
for c, w in geometric.items():
    print(f"  {c}: {w:.1%}")
Stack rank - Linear:
  scale: 28.6%
  latency: 23.8%
  cost: 19.0%
  ops: 14.3%
  compliance: 9.5%
  sdk: 4.8%

Stack rank - Geometric:
  scale: 50.8%
  latency: 25.4%
  cost: 12.7%
  ops: 6.3%
  compliance: 3.2%
  sdk: 1.6%

Linear distribuye pesos de forma gradual. Geometric amplifica drásticamente la diferencia entre el primer y último criterio. Usa geometric solo cuando tu criterio #1 domina completamente la decisión.


Escalas de scoring: rúbricas concretas

Un score sin rúbrica es una opinión con disfraz numérico. Define exactamente qué significa cada nivel:

Escala de 5 niveles (recomendada)

scoring_rubric = {
    1.0: {
        "label": "Cumple plenamente",
        "definition": "Supera el umbral ideal. Sin trade-offs relevantes.",
        "evidence_required": "Benchmark/docs que demuestren cumplimiento del umbral ideal"
    },
    0.75: {
        "label": "Cumple bien",
        "definition": "Cumple el umbral mínimo y se acerca al ideal. Trade-off menor.",
        "evidence_required": "Benchmark/docs + nota sobre el trade-off"
    },
    0.5: {
        "label": "Cumplimiento parcial",
        "definition": "Cumple el umbral mínimo pero lejos del ideal. Trade-off significativo.",
        "evidence_required": "Documentar el gap y plan de mitigación"
    },
    0.25: {
        "label": "Cumplimiento débil",
        "definition": "No cumple el umbral mínimo pero se acerca. Alto riesgo.",
        "evidence_required": "Documentar riesgo y workaround necesario"
    },
    0.0: {
        "label": "No cumple",
        "definition": "No cumple el umbral mínimo. Sin plan viable de mitigación.",
        "evidence_required": "Marcar como deal-breaker si el criterio es MUST"
    }
}

for score, details in scoring_rubric.items():
    print(f"\n{score}{details['label']}")
    print(f"  Definición: {details['definition']}")
    print(f"  Evidencia: {details['evidence_required']}")

Rúbricas específicas por dimensión

El significado de "0.75" depende del criterio. Aquí tienes rúbricas concretas:

dimension_rubrics = {
    "latency_p95": {
        1.0: "< 100ms (supera expectativa de real-time)",
        0.75: "100-250ms (bueno para chatbots/search)",
        0.5: "250-500ms (aceptable para batch/interno)",
        0.25: "500ms-1s (problemático para UX)",
        0.0: "> 1s (inaceptable para producción)"
    },
    "cost_monthly": {
        1.0: "< 50% del presupuesto (margen amplio)",
        0.75: "50-80% del presupuesto (viable)",
        0.5: "80-100% del presupuesto (sin margen)",
        0.25: "100-150% del presupuesto (requiere negociación)",
        0.0: "> 150% del presupuesto (fuera de rango)"
    },
    "ops_complexity": {
        1.0: "Managed, < 2h/mes mantenimiento",
        0.75: "Semi-managed, 2-5h/mes",
        0.5: "Self-hosted simple, 5-10h/mes",
        0.25: "Self-hosted cluster, 10-20h/mes",
        0.0: "Operación dedicada, > 20h/mes o SRE requerido"
    },
    "sdk_quality": {
        1.0: "SDK maduro, type hints, async, testing mode, docs excelentes",
        0.75: "SDK funcional, docs buenos, minor issues",
        0.5: "SDK básico, docs incompletos, workarounds necesarios",
        0.25: "SDK inmaduro, bugs frecuentes, docs mínimos",
        0.0: "Sin SDK en tu lenguaje o SDK abandonado"
    },
    "community": {
        1.0: "> 10K stars, activo, SO coverage, integraciones completas",
        0.75: "5-10K stars, activo, integraciones principales",
        0.5: "1-5K stars, actividad moderada, integraciones parciales",
        0.25: "< 1K stars, actividad baja, pocas integraciones",
        0.0: "Proyecto nuevo/abandonado, sin comunidad visible"
    },
    "compliance": {
        1.0: "SOC2 + GDPR + HIPAA + CMEK + audit logs",
        0.75: "SOC2 + GDPR + encryption at rest",
        0.5: "Encryption at rest + basic auth",
        0.25: "Solo auth básica, sin certificaciones",
        0.0: "Sin encryption ni certificaciones"
    }
}

print("=== Rúbrica: Latencia p95 ===")
for score, description in dimension_rubrics["latency_p95"].items():
    print(f"  {score}: {description}")

Normalización: comparar dimensiones diferentes

Latencia se mide en milisegundos, costo en dólares, ops en horas. Para que los pesos funcionen, necesitas normalizar todo a una escala común (0-1):

def normalize_score(
    raw_value: float,
    min_acceptable: float,
    ideal_value: float,
    lower_is_better: bool = True
) -> float:
    """
    Normaliza un valor raw a escala 0-1.

    Args:
        raw_value: Valor medido del proveedor
        min_acceptable: Umbral mínimo aceptable
        ideal_value: Valor ideal (1.0)
        lower_is_better: True para latencia/costo, False para throughput/recall
    """
    if lower_is_better:
        if raw_value <= ideal_value:
            return 1.0
        elif raw_value >= min_acceptable:
            return 0.25
        else:
            range_size = min_acceptable - ideal_value
            if range_size == 0:
                return 1.0 if raw_value <= ideal_value else 0.0
            normalized = 1.0 - ((raw_value - ideal_value) / range_size)
            return max(0.0, min(1.0, round(normalized, 2)))
    else:
        if raw_value >= ideal_value:
            return 1.0
        elif raw_value <= min_acceptable:
            return 0.25
        else:
            range_size = ideal_value - min_acceptable
            if range_size == 0:
                return 1.0 if raw_value >= ideal_value else 0.0
            normalized = (raw_value - min_acceptable) / range_size
            return max(0.0, min(1.0, round(normalized, 2)))


# Ejemplos de normalización
print("=== Normalización de latencia (lower is better) ===")
latencies = [50, 100, 200, 350, 500, 800]
for lat in latencies:
    score = normalize_score(lat, min_acceptable=500, ideal_value=100, lower_is_better=True)
    print(f"  {lat}ms → {score:.2f}")

print("\n=== Normalización de recall (higher is better) ===")
recalls = [0.80, 0.90, 0.95, 0.98, 1.0]
for recall in recalls:
    score = normalize_score(recall, min_acceptable=0.90, ideal_value=0.98, lower_is_better=False)
    print(f"  {recall:.0%}{score:.2f}")
=== Normalización de latencia (lower is better) ===
  50ms → 1.00
  100ms → 1.00
  200ms → 0.75
  350ms → 0.62
  500ms → 0.25
  800ms → 0.25

=== Normalización de recall (higher is better) ===
  80% → 0.25
  90% → 0.25
  95% → 0.62
  98% → 1.00
  100% → 1.00

ScoringEngine: implementación completa

Aquí tienes la clase que integra pesos, scoring y normalización:

from dataclasses import dataclass, field


@dataclass
class CriterionConfig:
    name: str
    weight: float
    min_acceptable: float
    ideal_value: float
    lower_is_better: bool = True
    unit: str = ""


@dataclass
class ProviderScore:
    name: str
    raw_scores: dict[str, float] = field(default_factory=dict)
    normalized_scores: dict[str, float] = field(default_factory=dict)
    weighted_scores: dict[str, float] = field(default_factory=dict)
    total_score: float = 0.0
    total_percentage: float = 0.0


class ScoringEngine:
    def __init__(self, criteria: list[CriterionConfig]):
        self.criteria = {c.name: c for c in criteria}
        self.providers: dict[str, ProviderScore] = {}

    def add_provider(self, name: str, raw_scores: dict[str, float]):
        provider = ProviderScore(name=name, raw_scores=raw_scores)

        for criterion_name, raw_value in raw_scores.items():
            config = self.criteria[criterion_name]

            normalized = normalize_score(
                raw_value,
                config.min_acceptable,
                config.ideal_value,
                config.lower_is_better
            )
            provider.normalized_scores[criterion_name] = normalized

            weighted = normalized * config.weight
            provider.weighted_scores[criterion_name] = round(weighted, 2)

        provider.total_score = sum(provider.weighted_scores.values())
        max_possible = sum(c.weight for c in self.criteria.values())
        provider.total_percentage = round(
            (provider.total_score / max_possible) * 100, 1
        )

        self.providers[name] = provider

    def ranking(self) -> list[ProviderScore]:
        return sorted(
            self.providers.values(),
            key=lambda p: p.total_score,
            reverse=True
        )

    def sensitivity_analysis(self, criterion_name: str, weight_range: list[float]) -> dict:
        """¿Cómo cambia el ranking si cambias el peso de un criterio?"""
        results = {}
        original_weight = self.criteria[criterion_name].weight

        for new_weight in weight_range:
            self.criteria[criterion_name].weight = new_weight
            for name, provider in self.providers.items():
                raw = provider.raw_scores
                self.add_provider(name, raw)

            ranking = [(p.name, p.total_percentage) for p in self.ranking()]
            results[new_weight] = ranking

        self.criteria[criterion_name].weight = original_weight
        for name, provider in self.providers.items():
            self.add_provider(name, provider.raw_scores)

        return results

    def report(self) -> str:
        lines = ["=== SCORING REPORT ===\n"]

        header = f"{'Criterio':<20} {'Peso':<6}"
        for p in self.ranking():
            header += f" {p.name:<15}"
        lines.append(header)
        lines.append("-" * len(header))

        for criterion_name, config in self.criteria.items():
            row = f"{criterion_name:<20} {config.weight:<6.1f}"
            for p in self.ranking():
                raw = p.raw_scores.get(criterion_name, 0)
                norm = p.normalized_scores.get(criterion_name, 0)
                row += f" {raw:>5.1f}{norm:.2f}    "
            lines.append(row)

        lines.append("-" * len(header))
        total_row = f"{'TOTAL':<20} {'':6}"
        for p in self.ranking():
            total_row += f" {p.total_percentage:>8.1f}%     "
        lines.append(total_row)

        return "\n".join(lines)


# Ejemplo completo
engine = ScoringEngine([
    CriterionConfig("latency_p95", weight=5.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True, unit="ms"),
    CriterionConfig("cost_monthly", weight=4.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True, unit="USD"),
    CriterionConfig("ops_hours", weight=4.0, min_acceptable=15,
                    ideal_value=2, lower_is_better=True, unit="h/month"),
    CriterionConfig("recall", weight=3.0, min_acceptable=0.90,
                    ideal_value=0.98, lower_is_better=False, unit="%"),
    CriterionConfig("sdk_score", weight=2.0, min_acceptable=0.5,
                    ideal_value=0.9, lower_is_better=False, unit="0-1"),
])

engine.add_provider("Pinecone", {
    "latency_p95": 120, "cost_monthly": 350,
    "ops_hours": 2, "recall": 0.96, "sdk_score": 0.85
})

engine.add_provider("Qdrant Cloud", {
    "latency_p95": 150, "cost_monthly": 200,
    "ops_hours": 3, "recall": 0.95, "sdk_score": 0.80
})

engine.add_provider("ChromaDB", {
    "latency_p95": 300, "cost_monthly": 60,
    "ops_hours": 10, "recall": 0.92, "sdk_score": 0.75
})

engine.add_provider("Weaviate Cloud", {
    "latency_p95": 180, "cost_monthly": 280,
    "ops_hours": 2, "recall": 0.94, "sdk_score": 0.70
})

print(engine.report())
print("\n=== RANKING ===")
for i, p in enumerate(engine.ranking(), 1):
    print(f"  #{i} {p.name}: {p.total_percentage}%")

Interpretación de resultados

No tomes el resultado como veredicto absoluto. Usa estas guías:

def interpret_score(percentage: float) -> dict:
    """Interpreta el score final de un proveedor."""
    if percentage >= 85:
        return {
            "verdict": "Excelente fit",
            "action": "Procede con PoC inmediato",
            "confidence": "Alta",
            "risk": "Bajo"
        }
    elif percentage >= 70:
        return {
            "verdict": "Buen fit con trade-offs",
            "action": "Procede con PoC, documenta mitigaciones",
            "confidence": "Media-alta",
            "risk": "Moderado — monitorea los criterios donde puntuó bajo"
        }
    elif percentage >= 55:
        return {
            "verdict": "Fit parcial",
            "action": "Solo si no hay alternativa mejor o hay constraint dominante",
            "confidence": "Media",
            "risk": "Alto — requiere plan de mitigación explícito"
        }
    else:
        return {
            "verdict": "No recomendado",
            "action": "Descarta salvo circunstancia excepcional",
            "confidence": "Baja",
            "risk": "Muy alto"
        }

# Gap analysis: ¿dónde pierde puntos cada proveedor?
def gap_analysis(provider: ProviderScore, criteria: dict[str, CriterionConfig]) -> list[dict]:
    """Identifica los criterios donde un proveedor pierde más puntos."""
    gaps = []
    for name, config in criteria.items():
        normalized = provider.normalized_scores.get(name, 0)
        if normalized < 0.75:
            lost_points = (1.0 - normalized) * config.weight
            gaps.append({
                "criterion": name,
                "normalized_score": normalized,
                "weight": config.weight,
                "points_lost": round(lost_points, 2),
                "raw_value": provider.raw_scores.get(name, 0)
            })
    return sorted(gaps, key=lambda g: -g["points_lost"])

Análisis de sensibilidad

¿Tu resultado cambia si ajustas un peso? Si sí, tu decisión es frágil:

def sensitivity_check(engine: ScoringEngine, criterion: str) -> None:
    """Verifica si el ranking es estable ante cambios de peso."""
    print(f"\n=== Sensibilidad: ¿qué pasa si cambio el peso de '{criterion}'? ===")

    results = engine.sensitivity_analysis(criterion, [1.0, 2.0, 3.0, 4.0, 5.0])
    for weight, ranking in results.items():
        leader = ranking[0]
        second = ranking[1]
        gap = leader[1] - second[1]
        stability = "🟢 estable" if gap > 5 else "🟡 close" if gap > 2 else "🔴 frágil"
        print(f"  Peso {weight}: #{1} {leader[0]} ({leader[1]}%) "
              f"vs #{2} {second[0]} ({second[1]}%) — gap: {gap:.1f}% {stability}")

# Ejemplo
sensitivity_check(engine, "cost_monthly")

Si al cambiar un peso de 3 a 5 el ganador cambia, tu decisión depende de ese criterio. Eso no está mal — pero debes ser consciente.


Sesgo en scoring: cómo detectarlo y reducirlo

bias_checklist = {
    "anchoring_bias": {
        "description": "El primer proveedor que evaluaste sesga los demás",
        "detection": "¿Puntuaste el primero más alto en casi todo?",
        "mitigation": "Puntúa todos los proveedores en un criterio antes de pasar al siguiente"
    },
    "familiarity_bias": {
        "description": "Puntúas más alto al proveedor que ya conoces",
        "detection": "¿El que usas actualmente gana por mucho?",
        "mitigation": "Incluye alguien del equipo que NO haya usado el proveedor actual"
    },
    "recency_bias": {
        "description": "El último blog post o tweet influencia tu score",
        "detection": "¿Tu evaluación cambió después de leer un artículo?",
        "mitigation": "Usa solo evidencia de docs oficiales, benchmarks y PoC propios"
    },
    "halo_effect": {
        "description": "Un criterio excelente infla los demás",
        "detection": "¿Un proveedor tiene casi todo en 1.0?",
        "mitigation": "Cada criterio se puntúa con su propia rúbrica, no por 'sensación general'"
    },
    "sunk_cost_bias": {
        "description": "Ya invertiste tiempo en un proveedor y no quieres descartarlo",
        "detection": "¿Buscas justificaciones para mantener el actual?",
        "mitigation": "Evalúa como si empezaras de cero (greenfield analysis)"
    }
}

print("=== Checklist de sesgos ===")
for bias, info in bias_checklist.items():
    print(f"\n⚠️ {bias}")
    print(f"  Descripción: {info['description']}")
    print(f"  Detección: {info['detection']}")
    print(f"  Mitigación: {info['mitigation']}")

🔧 Troubleshooting

Problema 1: "Todo me queda empatado"

Síntoma: Dos o más proveedores tienen scores dentro de 3-5% de diferencia.

Solución: Aumenta la resolución en los criterios que más peso tienen. Si "latency" tiene peso 5, subdivide en latency_p50, latency_p95, latency_p99. Otra opción: agrega un criterio "tiebreaker" como facilidad de migración o experiencia previa del equipo.

Problema 2: "El resultado contradice la intuición del equipo"

Síntoma: El ganador de la matriz no es el que el equipo "siente" que debería ganar.

Solución: No descartes la intuición — revisa si los pesos reflejan las prioridades reales. A veces la intuición captura información que no está en la matriz (experiencia negativa previa, relación con el vendor). Si después de revisar pesos los números siguen contradiciendo, documenta el override y su justificación.

Problema 3: "No tengo datos para puntuar objetivamente"

Síntoma: No has hecho benchmarks ni PoC y estás puntuando basándote en marketing.

Solución: Usa puntuación provisional (marca con ⚠️) basada en documentación y benchmarks públicos. Planifica un PoC de 2-3 días para los 2 proveedores top para validar los scores provisionales antes de decidir.

Problema 4: "Cada stakeholder produce un ranking completamente diferente"

Síntoma: CTO elige Pinecone, PM elige ChromaDB, Dev elige Qdrant.

Solución: Esto revela prioridades diferentes, no un problema de metodología. Agrega los pesos (promedio) y discute solo los criterios donde la divergencia es mayor a 2 puntos. El disagreement ES información valiosa.

Problema 5: "Un proveedor gana por los pesos, no por ser mejor"

Síntoma: Si cambias los pesos ligeramente, otro proveedor gana.

Solución: Haz análisis de sensibilidad. Si el resultado cambia con variaciones de ±1 en un peso, la decisión es frágil. En ese caso, prioriza el proveedor con menor riesgo operativo como criterio de desempate.


🏋️ Ejercicios

Ejercicio 1: Distribución fija de puntos

Distribuye 100 puntos entre estos 6 criterios para un proyecto de RAG interno (empresa mediana, 3 developers, sin SRE, 500K vectores esperados):

  • Latencia
  • Costo
  • Complejidad operativa
  • Calidad del SDK
  • Comunidad
  • Compliance
Solución
# Para RAG interno, empresa mediana, sin SRE, 500K vectores:
my_distribution = {
    "latency": 15,       # Interno: tolerancia moderada
    "cost": 20,          # Presupuesto limitado
    "ops_complexity": 30, # Sin SRE: OPS domina la decisión
    "sdk_quality": 15,   # 3 devs = SDK importa
    "community": 10,     # Importante pero no crítico
    "compliance": 10,    # Empresa mediana: requerimientos básicos
}

assert sum(my_distribution.values()) == 100, "Debe sumar 100"

print("Distribución:")
for criterion, points in sorted(my_distribution.items(), key=lambda x: -x[1]):
    bar = "█" * (points // 2)
    print(f"  {criterion:<20} {points:>3} pts {bar}")

# Ops domina porque sin SRE cada hora de mantenimiento
# es una hora que un developer no está construyendo features

Ejercicio 2: Comparación por pares

Realiza las 15 comparaciones por pares para los 6 criterios del ejercicio anterior. ¿El resultado coincide con tu distribución fija?

Solución
from itertools import combinations

criteria = ["latency", "cost", "ops", "sdk", "community", "compliance"]

# Para cada par: ¿cuál es más importante para RAG interno sin SRE?
my_preferences = {
    ("latency", "cost"): "cost",           # Interno: presupuesto > velocidad
    ("latency", "ops"): "ops",             # Sin SRE: ops siempre gana
    ("latency", "sdk"): "latency",         # Latencia > SDK
    ("latency", "community"): "latency",   # Latencia > comunidad
    ("latency", "compliance"): "latency",  # Latencia > compliance básico
    ("cost", "ops"): "ops",                # Sin SRE: ops gana vs costo
    ("cost", "sdk"): "cost",               # Presupuesto > SDK
    ("cost", "community"): "cost",         # Presupuesto > comunidad
    ("cost", "compliance"): "cost",        # Presupuesto > compliance
    ("ops", "sdk"): "ops",                 # Ops > SDK
    ("ops", "community"): "ops",           # Ops > comunidad
    ("ops", "compliance"): "ops",          # Ops > compliance
    ("sdk", "community"): "sdk",           # SDK > comunidad
    ("sdk", "compliance"): "sdk",          # SDK > compliance
    ("community", "compliance"): "community",  # Comunidad > compliance
}

weights = pairwise_comparison(criteria, my_preferences)
print("Pairwise weights:")
for c, w in sorted(weights.items(), key=lambda x: -x[1]):
    print(f"  {c}: {w:.1%}")

# Comparación: distribución fija → ops 30%, cost 20%, latency 15%
# Pairwise probablemente dará: ops > cost > latency (consistente)

Ejercicio 3: Scoring con rúbricas

Puntúa ChromaDB y Pinecone usando las rúbricas de la sección anterior para estos criterios: latency_p95, cost_monthly, ops_hours. Justifica cada score con evidencia.

Solución
scoring_with_evidence = {
    "ChromaDB": {
        "latency_p95": {
            "score": 0.5,
            "raw_value": "300-500ms (self-hosted, depende del hardware)",
            "evidence": "Sin benchmarks oficiales p95. Estimación de community reports.",
            "rubric_level": "Cumplimiento parcial"
        },
        "cost_monthly": {
            "score": 1.0,
            "raw_value": "$0-60/mes (self-hosted en VM pequeña)",
            "evidence": "Open source + costo de VM. Free tier efectivo hasta 100K.",
            "rubric_level": "Cumple plenamente"
        },
        "ops_hours": {
            "score": 0.25,
            "raw_value": "10-15h/mes (sin managed option madura)",
            "evidence": "Requiere monitoreo manual, backups manuales, updates.",
            "rubric_level": "Cumplimiento débil"
        }
    },
    "Pinecone": {
        "latency_p95": {
            "score": 0.75,
            "raw_value": "~120ms (según benchmarks públicos)",
            "evidence": "Benchmarks oficiales y ANN-benchmarks community.",
            "rubric_level": "Cumple bien"
        },
        "cost_monthly": {
            "score": 0.5,
            "raw_value": "$70-350/mes (depende del tier y vectores)",
            "evidence": "Pricing page oficial. Free tier hasta 100K vectors 1536d.",
            "rubric_level": "Cumplimiento parcial (para presupuesto < $200)"
        },
        "ops_hours": {
            "score": 1.0,
            "raw_value": "1-2h/mes (fully managed)",
            "evidence": "No requiere infra propia. Solo monitoring de integración.",
            "rubric_level": "Cumple plenamente"
        }
    }
}

for provider, criteria in scoring_with_evidence.items():
    print(f"\n=== {provider} ===")
    for criterion, data in criteria.items():
        print(f"  {criterion}: {data['score']} ({data['rubric_level']})")
        print(f"    Raw: {data['raw_value']}")
        print(f"    Evidencia: {data['evidence']}")

Ejercicio 4: Análisis de sensibilidad

Usando el ScoringEngine, verifica si tu ranking cambia al variar el peso de "cost_monthly" de 1 a 5. ¿La decisión es robusta o frágil?

Solución
engine_test = ScoringEngine([
    CriterionConfig("latency_p95", weight=4.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True),
    CriterionConfig("cost_monthly", weight=3.0, min_acceptable=500,
                    ideal_value=100, lower_is_better=True),
    CriterionConfig("ops_hours", weight=5.0, min_acceptable=15,
                    ideal_value=2, lower_is_better=True),
])

engine_test.add_provider("Pinecone", {
    "latency_p95": 120, "cost_monthly": 350, "ops_hours": 2
})
engine_test.add_provider("Qdrant Cloud", {
    "latency_p95": 150, "cost_monthly": 200, "ops_hours": 3
})
engine_test.add_provider("ChromaDB Self", {
    "latency_p95": 350, "cost_monthly": 60, "ops_hours": 12
})

results = engine_test.sensitivity_analysis("cost_monthly", [1.0, 2.0, 3.0, 4.0, 5.0])

print("Sensibilidad al peso de 'cost_monthly':")
for weight, ranking in results.items():
    leader = ranking[0]
    print(f"  Peso {weight:.0f}: Ganador = {leader[0]} ({leader[1]}%)")

# Si el ganador cambia de Pinecone a ChromaDB cuando cost sube a 5,
# tu decisión es sensible al criterio de costo. Documenta esto.

Ejercicio 5: Detección de sesgo

Revisa este scoring hipotético y detecta qué sesgos podrían estar presentes:

Criterio"Nuestro proveedor actual""Proveedor nuevo"
Latency0.90.7
Cost0.80.8
Ops0.90.6
SDK0.950.5
Community0.850.4
Solución
analysis = {
    "familiarity_bias": {
        "detected": True,
        "evidence": "El proveedor actual tiene scores > 0.8 en TODO. "
                    "Es estadísticamente improbable que sea superior en cada dimensión.",
        "action": "Incluir evaluador que NO haya usado el proveedor actual"
    },
    "halo_effect": {
        "detected": True,
        "evidence": "SDK 0.95 es sospechosamente alto. "
                    "¿Realmente el SDK es 'casi perfecto'? ¿O es que ya aprendiste sus quirks?",
        "action": "Aplicar rúbrica objetiva al SDK: type hints, async, docs, testing mode"
    },
    "anchoring_bias": {
        "detected": True,
        "evidence": "Si evaluaste el actual primero, los scores del nuevo se comparan "
                    "contra el actual en lugar de contra la rúbrica",
        "action": "Evalúa ambos contra la rúbrica, no uno contra otro"
    },
    "sunk_cost": {
        "detected": "Posible",
        "evidence": "Community 0.85 vs 0.4 es gap enorme. "
                    "¿El actual realmente tiene mejor comunidad, o simplemente TÚ conoces más su comunidad?",
        "action": "Medir comunidad con métricas objetivas: stars, SO answers, integration count"
    }
}

for bias, info in analysis.items():
    status = "🔴" if info["detected"] == True else "🟡"
    print(f"{status} {bias}: {info['evidence']}")
    print(f"   → {info['action']}")
    print()

🔗 Conexión con proyecto: Decision Questionnaire

En tu Decision Questionnaire, el ScoringEngine de esta cápsula es el motor de cálculo central:

  1. El cuestionario recolecta inputs → se convierten en CriterionConfig (pesos y umbrales)
  2. Los datos de proveedores → se alimentan como raw_scores
  3. El engine calcula → normalización + ponderación
  4. El reporte final incluye ranking, gap analysis y sensitivity check

Reutiliza el código de ScoringEngine, normalize_score y sensitivity_analysis directamente en tu proyecto.


Resumen

  • Los pesos determinan el resultado más que los scores. Invierte tiempo en los pesos.
  • Tres técnicas para asignar pesos: distribución fija (fuerza trade-offs), comparación por pares (intuitiva), stack ranking (rápida).
  • Sin rúbrica, el score es opinión. Define qué significa 0.5 vs 0.75 para cada criterio.
  • Normaliza antes de ponderar. Latencia en ms y costo en USD no se pueden sumar directamente.
  • Haz análisis de sensibilidad. Si cambiar un peso ±1 cambia el ganador, tu decisión es frágil.
  • Detecta sesgos sistemáticamente. Anchoring, familiarity, recency y halo effect son los más comunes.
  • Empates no se resuelven con más decimales. Si dos opciones están a ±3%, decide por simplicidad operativa.

Recursos adicionales

  1. Weighted Sum Model (Wikipedia) — Fundamento teórico de la weighted scoring matrix
  2. Pairwise Comparison Method — Método de comparación por pares para priorización
  3. MoSCoW Method — Framework de priorización complementario
  4. AHP (Analytic Hierarchy Process) — Método avanzado de decisión multi-criterio
  5. Cognitive Biases in Decision Making — Lista de sesgos cognitivos relevantes
  6. ANN Benchmarks — Datos objetivos para scoring de performance
  7. MCDA (Multi-Criteria Decision Analysis) — Framework académico para decisiones complejas
  8. Decision Quality Framework (SDG) — Framework profesional de calidad de decisiones

Tiempo estimado: 25-35 minutos Siguiente: 04-matriz-decision-practica.md