Technology Aug 24, 2026 · 19 min read

Construyendo un recomendador de emparejamiento de expertos

La forma del problema Un directorio es una superficie: el miembro lo abre y adivina. Un recomendador es una superficie de empujar: el sistema propone y tiene que justificarse. La justificación es la parte difícil, y es donde vive la estadística. Tres restricciones hicieron esto distinto...

DE
DEV Community
by Franchesco Romero
Construyendo un recomendador de emparejamiento de expertos

La forma del problema

Un directorio es una superficie: el miembro lo abre y adivina.
Un recomendador es una superficie de empujar: el sistema propone y tiene que justificarse. La justificación es la parte difícil, y es donde vive la estadística.

Tres restricciones hicieron esto distinto de un recomendador de contenido:

  1. El item es una persona con capacidad finita. Un hilo se le puede recomendar a diez mil personas. Un experto no.
  2. Una mala recomendación es cara de los dos lados. Quien pide desperdicia una petición, el experto desperdicia una hora, y los dos aprenden a ignorar la superficie.
  3. La afirmación tiene que ser checable. "Quizá te guste este hilo" no necesita evidencia. "Esta persona está un nivel adelante de ti en diseño de sistemas" sí.

Recuperación: híbrida, fusionada con RRF

Tres recuperadores independientes sobre el conjunto de expertos elegibles, fusionados con Reciprocal Rank Fusion:

def rrf_fuse(*ranked_lists, k=60):
    """Fusiona listas de ids rankeadas. El score depende solo del rank, nunca
    de la escala propia del recuperador, que es el punto: la similitud coseno y
    un conteo de hilos resueltos no son números comparables."""
    fused = {}
    for lst in ranked_lists:
        for rank, key in enumerate(lst):
            fused[key] = fused.get(key, 0.0) + 1.0 / (k + rank)
    return fused

RRF es la primitiva correcta aquí por una razón que vale la pena decir: los recuperadores emiten cantidades incomparables. Uno regresa un coseno en [-1, 1], uno regresa un conteo entero de hilos resueltos, uno regresa un delta de nivel de escalera. Normalizarlos a una escala común requiere supuestos sobre sus distribuciones que nadie tiene a este volumen de datos.
RRF descarta las magnitudes y se queda solo con el orden, que es exactamente la información que sobrevive a una muestra chica.

k = 60 es la constante estándar de la formulación original de Cormack et al.
Aplana la cabeza: la diferencia entre el rank 1 y el rank 2 es
1/61 - 1/62 ≈ 0.00026, así que un recuperador no puede dominar por estar confiado, solo por estar consistentemente temprano a lo largo de las listas.

Scoring: un compuesto ponderado, con los pesos como datos

SCORE_WEIGHTS = {
    "compass_gap_fit": 0.30,   # explícito y direccional
    "semantic_fit":    0.20,   # coseno del embedding de perfil
    "skill_overlap":   0.15,   # tecnologías compartidas, ponderadas por rareza
    "capacity_fit":    0.15,   # también una compuerta dura
    "expert_quality":  0.12,   # rating, aceptación, experiencia que satura
    "specialty_match": 0.05,   # burda, pero muy poblada
    "fairness":        0.03,   # penalización de exposición amortiguada por log
}

Dos componentes valen la pena desempacar.

expert_quality satura. Un experto sin historial puntúa 0.5, no 0:

def expert_quality(*, avg_rating, completed, accepted, proposed):
    if completed == 0 and proposed == 0:
        return 0.5                      # prior neutral, no cero
    rating_part = ((avg_rating or 4.0) - 1.0) / 4.0
    acceptance  = accepted / proposed if proposed else 0.6
    experience  = 1.0 - math.exp(-completed / 5.0)
    return 0.5 * rating_part + 0.3 * acceptance + 0.2 * experience

La saturación exponencial sobre experience codifica que la diferencia entre 0 y 5 sesiones completadas es grande y la diferencia entre 40 y 45 es ruido.
Un término lineal habría hecho inalcanzables a los veteranos. El prior de 0.5 para los no probados es la decisión de cold start que deja crecer el pool de expertos más allá de quien haya ido primero; sin él, el sistema es un loop de rico se hace más rico por construcción.

fairness es exposición amortiguada por log.

def fairness_factor(times_recommended):
    return 1.0 / math.log(math.e + max(0, times_recommended))

ln(e + n) da exactamente 1.0 en n = 0 y decae lento. Una penalización lineal habría hecho irrecomendable a un buen experto después de un puñado de ciclos.

La parte que importa: esto es un problema de asignación

El instinto es computar un top-N por cada quien pide. Ese instinto está mal, y el modo de falla no es sutil.

Si cada quien pide escoge de forma independiente a su mejor experto, las mismas tres personas más fuertes juntan todas las peticiones. Son las que tienen los mejores ratings y el historial más profundo, así que ganan cada ranking, y dejan de contestar en un mes. El recomendador entonces destruye el recurso que existe para asignar.

Así que los pares se puntúan, y luego se asignan de forma global bajo una restricción de capacidad por experto:

def allocate(pairs, *, capacity, per_requester):
    """Asignación global greedy. Ordenada por score, cada par consume una unidad
    de la capacidad de su experto."""
    assigned = defaultdict(int)
    out = []
    for pair in sorted(pairs, key=lambda p: p["score"], reverse=True):
        if capacity.get(pair["expert_id"], 0) <= 0:
            continue
        if assigned[pair["requester_id"]] >= per_requester:
            continue
        capacity[pair["expert_id"]] -= 1
        assigned[pair["requester_id"]] += 1
        out.append(pair)
    return out

Esta es la aproximación greedy a un matching bipartito con restricción de grado. A esta escala (cientos de pares) la solución óptima vía scipy.optimize.linear_sum_assignment y la greedy difieren por ruido, y la versión greedy tiene una propiedad que la óptima no: es inspeccionable en un dry run, línea por línea, en orden de score. Cuando un operador pregunta "¿por qué esta persona obtuvo ese experto?", la respuesta es una sola pasada hacia abajo por una lista ordenada.

A quienes piden que la asignación no puede colocar no se les tira. Por construcción son aquellos cuyos mejores expertos están llenos, lo que los hace el insumo exacto para el clustering uno-a-muchos: agrúpalos por celda de escalera y propón una sola sesión.

La capa de datos, y la estadística que la hace defendible

La segunda capa responde "qué debería aprender después, y cuánto vale".
Compara la mediana de datos ponderada por fuente de los puntos de datos que reportan una habilidad contra los que no.

Esa oración contiene tres maneras de engañar a alguien. Las tres necesitaron una compuerta.

1. Ponderación por fuente con decaimiento exponencial por recencia

No todos los puntos de datos merecen voto igual. Cada fuente carga un peso de confianza y una corrección de sesgo, y cada punto decae con la edad:

COALESCE(cs.base_weight, 0.50)
  * (1 + COALESCE(cs.bias_correction_pct, 0) / 100.0)
  * POWER(0.5, EXTRACT(EPOCH FROM (now() - sdp.scraped_at)) / (:halflife * 86400.0))

Una vida media de 365 días significa que una publicación de dos años todavía cuenta, a un cuarto del peso de una fresca. El LEFT JOIN sobre la tabla de fuentes es deliberado: un punto de datos cuya fila de fuente nunca se registró cuenta en el default neutral de 0.50 en lugar de desvanecerse, porque tirar datos en silencio es peor que ponderarlos de forma conservadora.

El agregado es una mediana ponderada, no una media ponderada. Las
distribuciones de datos están sesgadas a la derecha y la cola es donde viven los errores de scraping; una sola cifra mal parseada mueve una media y no mueve una mediana.

def weighted_median(values, weights):
    pairs = sorted(zip(values, weights), key=lambda p: p[0])
    total = sum(max(0.0, w) for _, w in pairs)
    if total <= 0:                       # todos los pesos en cero: degrada a mediana simple
        mid = len(pairs) // 2
        return (float(pairs[mid][0]) if len(pairs) % 2
                else (float(pairs[mid - 1][0]) + float(pairs[mid][0])) / 2.0)
    half, acc = total / 2.0, 0.0
    for value, weight in pairs:
        acc += max(0.0, weight)
        if acc >= half:
            return float(value)
    return float(pairs[-1][0])

2. Estratificación, porque el número ingenuo mide antigüedad

Este es el confusor que hace inútiles a la mayoría de las afirmaciones de "la habilidad X paga Y% más". La gente senior sabe más herramientas. Compara a todos los que reportan Kubernetes contra todos los que no, y una parte grande del delta es nada más antigüedad filtrándose por la comparación.

Cada comparación por lo tanto pasa dentro de un estrato (rol, seniority, país, bucket de experiencia). Los buckets son burdos a propósito (0-2, 3-5, 6-9, 10+): estratos más finos matan de hambre a la muestra y el intervalo explota. Una habilidad cuyo efecto desaparece una vez estratificada se tira, no se reporta.

3. Un intervalo por bootstrap, no un estimado puntual

Un estimado puntual se lee como una promesa. El bootstrap por percentil re muestrea las dos cohortes con reemplazo, recomputa las medianas ponderadas, y toma los percentiles 5/95 de la distribución de deltas resultante:

def bootstrap_ci(with_vals, with_w, without_vals, without_w, n=1000, alpha=0.10):
    rng = random.Random(SEED)            # determinista: los mismos insumos deben
    deltas = []                          # producir el mismo intervalo en cada corrida
    for _ in range(n):
        a = [rng.choice(range(len(with_vals))) for _ in with_vals]
        b = [rng.choice(range(len(without_vals))) for _ in without_vals]
        med_a = weighted_median([with_vals[i] for i in a], [with_w[i] for i in a])
        med_b = weighted_median([without_vals[i] for i in b], [without_w[i] for i in b])
        if med_b > 0:
            deltas.append((med_a - med_b) / med_b * 100.0)
    deltas.sort()
    lo = deltas[int(math.floor((alpha / 2) * len(deltas)))]
    hi = deltas[min(len(deltas) - 1, int(math.ceil((1 - alpha / 2) * len(deltas))) - 1)]
    return round(lo, 2), round(hi, 2)

El bootstrap es la herramienta correcta porque la distribución muestral de una mediana ponderada de una distribución sesgada no tiene una forma cerrada limpia. El re muestreo esquiva la derivación por completo.

El RNG sembrado importa más de lo que parece. Un operador refrescando un dry run de admin no debe ver el número bambolearse; un intervalo de confianza que cambia al recargar es indistinguible de un bug.

Una fila se surge solo si las tres se cumplen:

significant = (ci_low > 0.0                  # el intervalo se queda de un lado del cero
               and premium >= min_pct        # bastante grande para valer el tiempo de una persona
               and premium <= claim_cap_pct) # no un outlier absurdo

Las filas no significativas de todos modos se computan y se guardan. La tabla de admin muestra qué se rechazó y por qué, porque un número que el sistema se negó a usar es tan interesante como uno que usó.

La medición que reencuadró el proyecto

Todo lo de arriba estaba en verde en CI. Luego la primera dry run contra producción:

requesters=82  experts=1  scored_pairs=82  affinity=3  office_hours=0

1 experto de 51. La causa:

requester_ids = {m.user_id for m in requesters}
eligible_experts = [
    m for m in experts
    if m.open_load < cap and m.user_id not in requester_ids   # <-- esto
]

La intención era "no emparejes a alguien consigo mismo". La implementación era "excluye a cualquier experto que sea también un candidato a pedir", y casi todos los expertos lo son, porque no tienen ninguna petición abierta propia.

Medido:

Roles activos elegibles como experto:                51
...que se apuntaron:                                 51
...bajo el tope de carga:                            51
...excluidos por ser también quienes piden:             50

El único sobreviviente era la única persona que resultó estar a media
interacción, y las tres propuestas apuntaban a ella. El sistema había
encontrado el modo de falla de burnout por su cuenta, en la primera corrida, a través de una línea pensada para prevenir un problema completamente distinto.

La guarda de auto emparejamiento ya existía por par, que es donde va.

Dos hallazgos más de la misma corrida:

Las cards podían salir sin razón. Solo 4 miembros tenían una colocación de escalera de competencias, así que el componente más pesado casi siempre era 0 y la señal sobreviviente era un coseno de embedding que nadie puede leer. Una propuesta tenía una lista de razones vacía. Una card que no puede decir por qué es peor que ninguna card.

El badge de tendencia disparaba con todo. La ingesta era reciente, así que casi cada publicación caía en la ventana de 30 días y la línea base de 90 días era una o dos filas:

golang   229 publicaciones  trend=2.00
go       155 publicaciones  trend=462.00
python   109 publicaciones  trend=62.40
aws       43 publicaciones  trend=61.50

Una razón necesita un denominador que valga la pena dividir. El arreglo es un conteo mínimo de línea base antes de que se afirme una tendencia siquiera, y una cota sobre la razón.

La lección de frecuencia inversa, aprendida tres veces

Esta es la parte con el mayor valor de transferencia, porque la misma idea estadística tuvo que aplicarse en tres niveles distintos y cada nivel se veía bien hasta que se inspeccionó.

Nivel 1: lift, en el minero de adyacencia

La adyacencia de habilidades es minería clásica de reglas de asociación sobre habilidades co-ocurrentes en publicaciones de vacantes. Para una regla
A -> B:

  • soporte = conteo de transacciones que contienen las dos
  • confianza = P(B | A) = soporte / conteo(A)
  • lift = confianza / P(B)

La confianza sola es inútil, y la razón es instructiva. Linux co-ocurre con todo, así que P(Linux | lo que sea) es alta y cada regla apunta a Linux. El lift divide por la tasa base: si saber A no sube la probabilidad de B por encima del azar, el lift es 1 y la regla no carga información.

lift = confidence / (singles[consequent] / total)
if lift < min_lift:      # 1.15
    continue

Reglas que sobrevivieron sobre datos reales, ordenadas por confianza * lift:

laravel        -> php            conf=0.73  lift=11.33  support=8
terraform      -> kubernetes     conf=0.75  lift=9.25   support=9
rails          -> ruby           conf=0.78  lift=7.94   support=7
react native   -> react          conf=1.00  lift=5.48   support=7
gcp            -> aws            conf=0.73  lift=6.15   support=16

Esas se leen bien para un humano, que es la única validación disponible a este tamaño de muestra.

Una trampa que vale la pena registrar: las primeras pruebas unitarias de esto eran degeneradas. Un corpus donde el antecedente aparece en cada transacción tiene lift 1.0 por construcción, así que nada puede ser significativo jamás.

Los fixtures tuvieron que reescribirse para incluir publicaciones que no contuvieran ningún lado de la regla. Un corpus de mercado es diverso; un corpus de prueba tiene que serlo también, o no prueba nada.

Nivel 2: frecuencia de documento, en la señal de tema

El riel salió y casi cada card decía:

Comparten temas: introduccion-plataforma.

La distribución de frecuencia de tags lo explica:

talento-tecnologico        27 hilos   50.0%
introduccion-profesional   17 hilos   31.5%
introduccion-plataforma    14 hilos   25.9%
espacio-relajacion          4 hilos    7.4%
servidores-caseros          4 hilos    7.4%
ai-local-vs-nube            2 hilos    3.7%

Tres tags cubren la mitad de un corpus de 54 hilos, luego un acantilado a 7%.

Este es el problema de Linux otra vez, un nivel arriba: un tag sobre la mitad del corpus no carga información sobre un par. Los tags por encima de 20% de frecuencia de documento se tiraron del scoring y del copy.

Ese arreglo hizo el copy menos vergonzoso sin hacerlo significar nada, lo que lleva al tercer nivel.

Nivel 3: el vocabulario era la ontología equivocada

Existían nueve tags en total. Ninguno de ellos era una tecnología.
Eran secciones del foro: un tablero de anuncios, dos áreas de introducción, un espacio fuera de tema. Ningún umbral de frecuencia puede rescatar una señal que está midiendo lo equivocado. Compartir "introducciones" significa que las dos personas se presentaron.

La señal técnica que sí existía era el tech_stack del perfil:

python 11 · aws 8 · docker 7 · node.js 7 · typescript 7
git 6 · react 6 · fastapi 5 · kubernetes 3 · terraform 3

Flaca (20 de 83 miembros) pero real, y nombra cosas que un experto puede enseñar. El componente de tema se reemplazó por completo, normalizado por el menor de los dos stacks para que un stack de 30 items no pueda diluir un buen match de 3-de-4, y extendido a través de las reglas de adyacencia minadas para que quien pide sobre Python conecte con un experto sobre FastAPI con la regla como la justificación declarada.

Y luego, predeciblemente, el nivel 3 tuvo su propio nivel:

También trabaja con python.

Python está en 11 de 20 stacks. El mismo problema, tercera recursión. La resolución esta vez no fue un filtro duro, porque a diferencia de una sección de foro, Python es una habilidad enseñable real y ponerla en cero descarta traslape genuino.

Las habilidades ubicuas se bajan de peso a un quinto en lugar de tirarse, el copy nombra las habilidades más raras
primero, y un stack compartido solo justifica una card cuando al menos una habilidad compartida es poco común:

weight_of = lambda sk: 0.2 if sk in common_skills else 1.0

Una habilidad compartida rara ahora le gana a una ubicua 1.0 a 0.2, que es lo que pone a Terraform encima de Python en el ranking en lugar de debajo por accidente de quién lista más tecnologías.

La generalización: cada vez que una señal es un traslape de conjuntos, pregúntate cuál es la tasa base de cada elemento antes de dejar hablar al traslape. El lift, el IDF, y este bajado de peso de habilidades son la misma corrección con tres nombres distintos.

Afinado de pesos como learning to rank offline

Los pesos compuestos se guardan en config, no en código, y hay una dry run que propone un rebalanceo a partir de los resultados calificados de los propios miembros:

correlations = {k: safe_corr(np.asarray(per_key[k]), y) for k in keys}

Tres decisiones deliberadas alrededor de ella:

La clase positiva es una sesión agendada, no un click. Un click en un hilo es barato; convertir una propuesta en una sesión de verdad agendada no.

Optimizar por clicks afinaría el ranking hacia la curiosidad en lugar de hacia sesiones que pasan.

La propuesta es una mezcla conservadora, nunca el vector crudo derivado de la correlación:

blended = {k: BLEND * corr_w.get(k, 0.0) + (1.0 - BLEND) * current.get(k, 0.0)
           for k in current}

Con BLEND = 0.5 y un piso de 30 muestras, una semana flaca no puede mover el ranking.

Nada se aplica automáticamente. Un operador previsualiza y aplica de forma explícita. Auto aplicar una correlación computada sobre decenas de muestras es como un ranker oscila.

Trampas

Trampa 1: la restricción de unicidad que creó duplicados. Las filas son únicas por (requester, expert, cycle_key), lo cual es correcto por ciclo y mal a través de ellos. El cron semanal escribió un segundo ciclo encima del primero y el riel de inmediato se dobló:

riel para <miembro>:
   <experto-a>  0.3997
   <experto-a>  0.3871   <--
   <experto-b>  0.3589
   <experto-b>  0.3462   <--

Arreglado en dos lugares, porque cualquiera solo deja un hoyo: la lectura se colapsa con DISTINCT ON (expert_id) quedándose con la mejor fila, y un ciclo nuevo reemplaza las propuestas sin tocar de los viejos para que la tabla no pueda crecer una card por persona por semana para siempre. Solo se tiran las filas no mostradas; cualquier cosa que un miembro vio es historial de embudo.

Trampa 2: el CTA que no hacía nada. El riel se renderizaba dentro de la misma página a la que enlazaba su botón, así que el click actualizaba el query string y la página se quedaba ahí. Peor, el enlace no cargaba id de match, así que una petición enviada nunca podía reportar de vuelta y la máquina de estados nunca podía avanzar más allá de CLICKED. La tasa de aceptación, la única métrica sobre la que se juzga el rollout y la que está cableada a la alarma, se habría leído como cero para siempre. El botón muerto se habría
reportado en un día; la métrica muerta se habría creído por semanas.

Trampa 3: una auto-recomendación latente. Cero filas en producción tenían requester_id = expert_id, pero el clustering uno-a-muchos escogía su experto del pool completo sin excluir el cluster, y esas filas cargan al primer miembro del grupo como quien pide. Podía apuntarse a sí mismo. Simplemente nunca había
disparado porque ningún cluster había alcanzado el umbral.

Lecciones

  1. Mide contra datos de producción antes de confiar en un diseño. La primera dry run contra filas reales invalidó más del diseño que todas las pruebas juntas. Córrela antes de que se emita nada, no después.

  2. Un recomendador para un recurso finito es un problema de asignación. El top-N por usuario es la forma equivocada y su modo de falla es destruir el lado de la oferta. La restricción de capacidad va en la asignación, no en un post filtro.

  3. Chequea la tasa base de cualquier señal de traslape de conjuntos. El lift, la frecuencia de documento y la rareza de habilidad son la misma corrección. Una señal compartida por la mitad de la población no carga información sobre un par, no importa qué tan cierta sea.

  4. Antes de afinar un umbral, verifica el vocabulario. Se gastaron dos rondas en cortes de frecuencia para un conjunto de tags que no contenía ninguna tecnología en absoluto. Pregúntate qué son las etiquetas antes de preguntar qué tan comunes son.

  5. Un intervalo de confianza es una decisión de producto, no un detalle de estadística. El intervalo, el piso de muestra y la cota de efecto son lo que se para entre una correlación y una promesa que los datos no pueden cumplir. Muestra el rango, declara la muestra, y niégate a imprimir el número cuando las compuertas fallan.

  6. Siembra el bootstrap. Un número que cambia al recargar es indistinguible de un bug, y destruye la confianza del operador más rápido que estar mal una vez.

  7. Instrumenta la conversión antes de publicar la superficie. Un botón muerto se reporta en un día. Una métrica muerta se cree por un mes.

  8. Publica el motor a oscuras. Emisión apagada por default y una dry run como la acción de admin por default significaron que cuatro bugs significativos se encontraron con datos reales y cero notificaciones enviadas.

DE
Source

This article was originally published by DEV Community and written by Franchesco Romero.

Read original article on DEV Community
Back to Discover

Reading List