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. Por componente, la correlación punto-biserial con el resultado
binario:

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

La punto-biserial es solo Pearson con una variable dicotómica, que es lo que
el resultado es. 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 :
     0.3997
     0.3871   <--
     0.3589
     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.

Lo que no ayudó

  • Filtrar los tags ubicuos del foro. Hizo el copy menos vergonzoso y no
    cambió nada sobre su significado. El vocabulario estaba mal, no el umbral. Se
    gastaron dos commits en el umbral antes de que se cuestionara la ontología.
  • Agregar una prueba. Cada uno de los cuatro hallazgos de producción era
    invisible para una suite en verde. Las pruebas verifican que el código hace
    lo que se le dijo; no pueden verificar que lo que se le dijo tenga sentido
    contra datos que todavía no existen.
  • Un pool de candidatos más grande. La recuperación nunca fue el cuello de
    botella. La elegibilidad y la cobertura de señal lo eran.

Qué ayudaría después, en orden de palanca

Llenar los campos de perfil que las señales leen. Cero de 51 expertos
tenían disponibilidad publicada, 4 miembros tenían una colocación de escalera,
20 de 83 tenían tecnologías listadas. La mejor card de toda la corrida vino de
la colocación de escalera, la señal con 5% de cobertura. Ningún cambio de
modelado compite con mover esos tres números, y nada de eso es un problema de
código.

Pasar la muestra de datos por las compuertas. 52 de 67 premiums candidatos
se rechazaron por muestra insuficiente, y de los 4 que sobrevivieron, 3 eran un
solo artefacto: premium idéntico, intervalo idéntico, n=12/39 idéntico,
porque doce publicaciones listan Linux, Git y Rust juntos. "Git paga 21.9% más"
es la afirmación que la cota existe para detener. La capa se queda apagada
hasta que la muestra la soporte.

Pasar de una media Beta por tag a muestreo de Thompson. La afinidad de
feedback ya se guarda con forma Beta, (clicks + 1) / (clicks + dismisses + 2),
que es la media posterior de un prior Beta(1,1). Muestrear de esa posterior
en lugar de tomar su media convierte los slots de exploración de una
reservación fija en un bandit con principios. No vale la pena antes de que haya
suficiente feedback para que la posterior difiera del prior.

Habilidades correlacionadas en el cómputo del premium. El artefacto de
Linux/Git/Rust es un problema de multicolinealidad: los tres no son regresores
independientes. Una correlación parcial, o agrupar habilidades co-ocurrentes
antes de comparar, lo atraparía automáticamente en lugar de depender de que un
operador note tres filas idénticas.

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.


Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Building an Open Turkish EV Charging Intent Dataset

Related Posts