12°
Portada del artículo: Reglas duras contra Random Cut Forest sobre el mismo stream: el modelo aporta 13 puntos de recall y cuesta 7 veces el throughput
StreamingBenchmarksDatosRiesgo

Reglas duras contra Random Cut Forest sobre el mismo stream: el modelo aporta 13 puntos de recall y cuesta 7 veces el throughput

Monté Apache Flink con Kafka y medí tres reglas deterministas contra el Random Cut Forest de Amazon sobre exactamente el mismo flujo de pagos, en un servidor de cuatro núcleos sin GPU. Las reglas alcanzan 0,814 de recall a 173 mil eventos por segundo; el modelo llega a 0,943 pero baja el pipeline a 23 mil; juntos suben a 0,982.

Efrain Garay 10 de septiembre de 2026

Las reglas de riesgo envejecen mal. Monto sobre cierto umbral, tantas operaciones en tantos segundos, país distinto al anterior: todas funcionan, todas son fáciles de explicar ante una auditoría y todas fallan exactamente igual, contra el patrón que nadie escribió.

La respuesta habitual es agregar un modelo de anomalías. Amazon publicó el suyo, Random Cut Forest, y lo ofreció durante años como una función de SQL en Kinesis. Esa función ya no está, pero la librería sí: es software libre y se puede meter en cualquier proceso Java.

Así que armé el experimento honesto: el mismo flujo de pagos, las mismas anomalías inyectadas y etiquetadas, y tres formas de mirarlo. Reglas solas, modelo solo, y ambos en un mismo pipeline.

El modelo agrega 12,9 puntos de recall sobre las reglas y baja el throughput de 173.297 a 23.533 eventos por segundo. Juntos llegan a 0.982, y el 17% de las anomalías las ve únicamente el modelo.

En 43 segundos: la regla que sella lo que ya estaba escrito, el bosque que mira el flujo entero, y el precio medido de meterlo dentro del pipeline. Todos los números son los medidos. Sin sonido por defecto: actívalo en los controles.Verlo en el visualizador de reels →

Qué monté

Apache Flink 2.3.0 y Kafka 4.3.1 en contenedores, sobre un servidor de 4 núcleos virtuales, 16 GB de RAM y sin GPU. El generador produce transacciones de tarjeta sintéticas con tres tipos de anomalía inyectada y etiquetada: monto alto, ráfaga de operaciones y cambio de país imposible.

Las tres reglas deterministas, con estado por tarjeta:

  • monto sobre 500.000 pesos,
  • cuatro operaciones o más en 60 segundos,
  • país distinto al de la operación anterior dentro de 10 minutos.

El modelo es ThresholdedRandomCutForest 4.4.0, con 30 árboles, muestra de 256 y tres variables por evento: monto, tiempo desde la operación anterior de esa tarjeta y si cambió el país.

Primero, que las reglas no mientan

Antes de comparar contra nada escribí las mismas tres reglas dos veces: una en el job de Flink y otra en veinte líneas de Python. La versión en Python es el árbitro. Si las dos no producen exactamente el mismo conjunto de alertas sobre el mismo archivo, el problema es mío y no del framework.

Sobre 1.016.212 eventos, el job produjo 113.800 alertas: las mismas 113.800, sin una de más ni una de menos. Recién ahí empecé a medir.

Instalación

docker compose up -d          # kafka 4.3.1 en modo KRaft, jobmanager y taskmanager
docker run --rm -v $PWD/job:/w -w /w maven:3.9-eclipse-temurin-17 mvn -B package
docker exec srk-jm flink run -d -p 1 -c cl.efg.streamrisk.HybridJob /jars/stream-risk-0.1.0.jar

Lo que se rompió

El conector de Kafka no compila solo. flink-connector-kafka 5.0.0-2.2 usa DeliveryGuarantee, que vive en flink-connector-base, y ese artefacto no llega como dependencia transitiva: el propio POM del conector lo declara con alcance provided. El error es package org.apache.flink.connector.base does not exist y desaparece agregando la dependencia a mano con la versión de Flink, también como provided, porque en tiempo de ejecución la aporta la distribución.

El orden de los argumentos de flink run decide qué job corre. Escribí flink run -d -p 1 /jars/app.jar -c cl.efg.streamrisk.RcfJob y Flink lo aceptó sin una sola advertencia: todo lo que va después del jar es argumento del programa, así que ejecutó la clase principal del manifiesto, que eran las reglas. Perdí varios minutos analizando resultados del job equivocado hasta que miré el nombre en la interfaz web. La clase va antes del jar.

Kafka 4 movió las herramientas. kafka.tools.GetOffsetShell ya no existe y devuelve ClassNotFoundException. El reemplazo es el script kafka-get-offsets.sh.

El bosque no entra en el estado de Flink. Los objetos de Random Cut Forest no son serializables por Flink, así que no se pueden guardar en un ValueState. Terminé sosteniéndolos en un mapa dentro del operador. En este montaje eso no marca diferencia porque tampoco activé los checkpoints, así que un reinicio se lleva por delante reglas y modelo por igual; la diferencia aparece en cuanto se activan, porque el estado de las reglas vuelve solo y el del modelo no. Para producción habría que serializar el bosque con el mapper que trae la propia librería y pagar ese costo por evento, o aceptar el período de calentamiento después de cada reinicio.

Las métricas, medidas por mí

Servidor de 4 vCPU, 16 GB de RAM, sin GPU. Mismo archivo de entrada para todos: 101.668 eventos, 2.000 tarjetas, 2.506 anomalías etiquetadas.

MétricaReglasRandom Cut ForestAmbos
Recall0.8140.9430.982
Precisión0.1820.3390.162
Eventos marcados11.1986.97015.176
Costo del operador, mediana1,8 µs21 µs23 µs
Costo del operador, media3,4 µs41,5 µs44,9 µs
Costo del operador, percentil 9914,5 µs528 µs540 µs
Throughput con paralelismo 1173.297 ev/s23.533 ev/s
Quién atrapa qué, sobre las mismas 2.506 anomalíasUna sola corrida de 101.668 eventos en un servidor de 4 núcleos sin GPU. Elige una mirada.
Monto alto387 de 387387 de 387387 de 387
Ráfaga de operaciones1.213 de 1.6681.533 de 1.6681.631 de 1.668
Salto de país440 de 451444 de 451444 de 451
Falsos positivos9.158 sobre 99.162 eventos normales4.606 sobre 99.162 eventos normales12.714 sobre 99.162 eventos normales

Tres umbrales con estado por tarjeta. Ve todos los montos altos y los saltos de país con una operación previa cercana, y pierde 455 eventos de ráfaga: los que ocurren antes de la cuarta operación, cuando la regla de conteo aún no puede disparar.

Un solo bosque para todo el flujo, con las variables calculadas por tarjeta. Recupera 320 de esos eventos, casi todos el primero o el segundo de su ráfaga, y marca la mitad de falsos positivos que las reglas.

La unión de las dos miradas llega a 0.982 de recall. El precio es la suma de los falsos positivos: 12.714, un 12,8% del flujo normal.

Latencia de punta a punta, del instante en que nace el evento al instante en que sale la decisión, con el pipeline completo:

Ritmo de entradaMedianaPercentil 95Percentil 99Máximo
5.000 ev/s47 ms76 ms86 ms134 ms
20.000 ev/s, últimos 30 s de 2 minutos5.834 ms7.894 ms8.137 ms8.205 ms

La segunda fila no es una latencia estable, y ese es el hallazgo. A 20.000 eventos por segundo el pipeline avanza más rápido que la entrada, pero apenas: la corrida arrancó con 19 segundos de retraso acumulado y, dos minutos después, la decisión seguía saliendo 4,6 segundos detrás del evento. El retraso baja unos 0,12 segundos por cada segundo transcurrido, así que digerir ese arranque toma más de dos minutos y medio. Los dos números de holgura no son el mismo: contra los 23.533 eventos por segundo del drenado, 20.000 de entrada dejan un 15%; medido en vivo por lo que baja el rezago, el pipeline rinde unos 22.400 y la holgura real es del 12%, porque la misma máquina corre además el generador, el productor y el broker. Con 12%, ningún tropiezo se perdona.

Dónde se rompe: la latencia de punta a punta a dos ritmosEscala logarítmica. La columna de 5.000 descarta los primeros 10 segundos de arranque; la de 20.000 son los últimos 30 segundos de una corrida de dos minutos.
5.000 ev/s254.978 eventos
1 s100 ms47 msp5076 msp9586 msp99134 msmax

21% de la capacidad medida. El pipeline va al día.

20.000 ev/s600.000 eventos
1 s100 ms5.834 msp507.894 msp958.137 msp998.205 msmax

85% de la capacidad. Últimos 30 s de 2 minutos: el retraso baja 0,12 s por segundo y aún no se digiere.

Capacidad medida del pipeline: 23.533 ev/s

Memoria residente del TaskManager: 672 MiB en vacío, 1,607 GiB corriendo el job con un bosque, 2,02 GiB con dos mil. El primer salto es sobre todo el montón de la máquina virtual creciendo bajo carga, no el modelo. Lo que sí atribuyo al modelo es la diferencia entre las dos últimas cifras: unos 217 KB por bosque, y aun así es aproximado porque hay un recolector de basura en el medio.

Cómo leer esto

El modelo ve lo que las reglas todavía no pueden ver. De las 2.506 anomalías, 422 las detectó únicamente el bosque, y al mirar cuáles son aparece el motivo exacto: 283 son la primera operación de una ráfaga y 135 la segunda. La regla cuenta cuatro operaciones en 60 segundos, así que por construcción no puede disparar hasta la cuarta; el modelo marca la primera porque el hueco desde la operación anterior es cien veces más corto de lo normal para esa tarjeta: 125 milisegundos de mediana contra 39.554 del tráfico corriente. No es que la regla sea peor, es que llega tres operaciones más tarde. Al revés también pasa: 44 anomalías no las vio nadie.

Cada uno cubre un hueco distinto. El salto de país es el caso claro: la regla detecta 440 de 451 y el bosque 444, pero solo 4 de esos los ve el modelo en soledad, así que quitar la regla no cambia el resultado y quitar el modelo tampoco. Donde el reparto sí importa es en las ráfagas: sobre 417 ráfagas de cinco operaciones cada una, la regla ve 1.213 de los 1.668 eventos etiquetados y el modelo 1.533, porque llega antes al principio de cada una.

El mismo modelo rinde muy distinto según qué le des de comer. Con las tres variables calculadas globalmente, el bosque llegó a 0.769 de recall. Con las mismas tres variables calculadas por tarjeta en la etapa anterior del pipeline, subió a 0.943. Mismos árboles, misma semilla, mismos parámetros: 17,4 puntos de recall salieron de dónde se calcula el tiempo entre operaciones.

Un modelo por cliente suena bien y no funciona. Probé 2.000 bosques, uno por tarjeta. Marcaron 9 eventos en total, un recall de 0.001, y encima corrieron 3,8 veces más lento por evento. La causa es un número concreto: la librería no emite puntaje hasta haber visto 64 puntos, valor que comprobé leyendo getOutputAfter() en un bosque construido con los parámetros del job. La mediana de operaciones por tarjeta en mi archivo es 51, y solo el 4,8% de las tarjetas llega a 64. El 95% de los modelos nunca llegó a hablar.

Un bosque por tarjeta: 2.000 modelos, 9 alertasCada punto es una tarjeta. Encendidas, las 96 que juntaron las 64 operaciones que el algoritmo exige antes de emitir un puntaje.
Arranque exigido
64 operaciones
Mediana por tarjeta
51 operaciones
Tarjetas que llegan
96 de 2.000 · 4,8%
2.000 bosques, uno por tarjeta9 alertasrecall 0,001
Un bosque para todo el flujo6.970 alertasrecall 0,943

Mismos árboles, misma semilla, mismas variables. Lo único que cambia es cuántas operaciones ve cada modelo.

El precio del modelo, medido en eventos por segundoMismo archivo de 1.016.212 eventos drenado tres veces. 4 núcleos virtuales, sin GPU.
Solo reglas · paralelismo 1173.297 ev/sCoste por evento1,8 µsmediana14,5 µspercentil 99

Tres umbrales con estado. El techo del pipeline sin modelo.

Reglas + bosque · paralelismo 123.533 ev/sCoste por evento23 µsmediana540 µspercentil 99

El bosque cuesta 7,36 veces el throughput.

Reglas + bosque · paralelismo 423.972 ev/sCoste por evento23 µsmediana540 µspercentil 99

Cuatro núcleos disponibles, tres mirando: la etapa del bosque depende de una sola clave, y el topic tiene una sola partición.

Las barras están a escala real: la de las reglas es 7,36 veces la del pipeline con modelo.

El bosque global mata el paralelismo. Al ser un solo modelo, su etapa depende de una sola clave, así que corre en un solo hilo pase lo que pase. Subir de paralelismo 1 a 4 movió el throughput de 23.533 a 23.972 eventos por segundo: un 1,9%. Hay que decir que el tope no es solo del modelo: el topic tiene una sola partición, así que la lectura tampoco se reparte. Aun arreglando eso, la etapa del bosque seguiría siendo un hilo, y con 4 núcleos disponibles, tres miran.

Lo que esta medición no prueba

Los datos son sintéticos y las anomalías son las que yo inyecté, así que los números miden capacidad de separar señales conocidas, no fraude real.

La precisión baja tiene una causa concreta que conviene mirar de frente, porque al principio la atribuí al etiquetado y los datos dicen otra cosa. La regla de velocidad produce 8.755 falsos positivos, y de esos solo 437 tienen una ráfaga inyectada de la misma tarjeta en los 60 segundos previos: los otros 8.318 son tráfico normal. Con 2.000 tarjetas repartidas en 2.917 segundos, cada tarjeta recibe cerca de 1,1 operaciones por minuto, y “cuatro en 60 segundos” ocurre por azar con esa densidad. El umbral está mal calibrado para el tráfico que yo mismo generé. Eso no invalida la comparación, porque las tres columnas comen exactamente el mismo archivo, pero significa que la columna de precisión mide mi umbral y no la capacidad del método: lo comparable aquí es el recall.

El modelo tiene su propio artefacto: 323 de sus alertas son el primer evento de una tarjeta, donde el hueco desde la operación anterior vale cero porque no hay operación anterior. Ese cero está a varios órdenes de magnitud del resto de la distribución y el bosque lo lee como rareza. Codificar “sin historia” aparte, en vez de como un cero, se lleva por delante ese grupo.

Lo dejé corriendo sobre tráfico real

Un experimento con datos sintéticos termina cuando se acaba el archivo. Este no: dejé las mismas reglas, más una para los handshakes mTLS, y el mismo bosque mirando el tráfico real de todo lo que sirve este servidor, subdominios incluidos, las autenticaciones del servidor y las menciones públicas del nombre, y el resultado se publica solo en el tablero del lab.

Desde el 10 de septiembre, en vivo, cada host tiene además su propio bosque, que toma la decisión recién cuando juntó 64 eventos; mientras tanto decide el global. Es la lección de más arriba aplicada al revés: un bosque con poca historia no habla, así que no se le deja decidir hasta que la tenga.

El camino de un evento, del sitio al tableroTres colectores, un topic, las reglas y el bosque, y el tablero en este mismo sitio. Cada etapa es la que está corriendo.
El camino de un evento, del sitio al tableroA diagram generated by Archify.01 / Fuentes02 / Kafka03 / Flink04 / Decision05 / PublicacionCaddy · cada peticion · 01 / Fuentes · subred /24Caddycada peticionsubred /24sshd · aceptada o fallida · 01 / Fuentes · journalsshdaceptada o fallidajournalMenciones · news, HN, GitHub · 01 / Fuentes · cada 30 minMencionesnews, HN, GitHubcada 30 minefg_events · un solo esquema · 02 / Kafka · 1 particionefg_eventsun solo esquema1 particionReglas por clave · 5 umbrales · 03 / Flink · 1,8 usReglas por clave5 umbrales1,8 usRandom Cut Forest · 30 arboles, 256 · 03 / Flink · 21 usRandom Cut Forest30 arboles, 25621 usefg_decisions · regla o modelo · 04 / Decision · todoefg_decisionsregla o modelotodoRegistro en el repo · una linea por alerta · 05 / Publicacion · cada 6 hRegistro en el repouna linea por alertacada 6 h/lab/riesgo · tablero publico · 05 / Publicacion · publico/lab/riesgotablero publicopublicopeticionestado, duracion y TLSauthaceptada o fallidamencionquien publicakeyBy(clave)estado por subred3 variablestamano, hueco, clave nuevapuntajey su umbraloffsetsin huecos ni repetidosse republicabuild con gatesLeyendacamino principaldonde se guardaflujo de datos

La diferencia con lo que acabas de leer es que ahí no hay etiquetas. Nadie marca qué petición era un sondeo, así que el recall no se puede calcular. Lo que sí se puede contar, y es lo que cuenta el tablero, es cuánto dispara cada regla y cuántas veces el modelo marca algo que ninguna regla nombró. Ese número es el que decide si el bosque aporta fuera del laboratorio.

Las reglas que quedaron mirando el sitio no son las de las tarjetas, porque el tráfico es otro: ruta que nadie navega a mano (/.env, /wp-login.php, /.git/config), ráfaga de 120 pedidos por minuto desde una misma subred, tres fallos de ssh en diez minutos, un 5xx en un sitio que es estático, y una fuente que nunca había nombrado el dominio. El bosque, en cambio, es el mismo de siempre, con tres variables: tamaño de la respuesta, hueco desde el evento anterior de esa clave, y si la clave es nueva.

Dos cosas que aprendí en las primeras horas y que valen para cualquiera que monte esto. La primera: mis propias pruebas contaban como ataques. Pedir /.env con curl desde el propio servidor entra por el puente de docker, así que llega con la dirección de la pasarela y el pipeline la marca como si fuera un tercero; hubo que descartar el tráfico que nace en la propia caja. La segunda: las direcciones llegan recortadas a /24 porque el propio Caddy las enmascara antes de escribir su registro, así que la unidad de todo esto es la subred del visitante, nunca su dirección exacta.

El tablero se reconstruye cada seis horas, que es tarde para mirar algo que pasa ahora. Para eso está el flujo en vivo: una página que el servidor deja abierta y va escribiendo, una fila por evento en el instante en que el pipeline decide. Sin JavaScript, sin WebSocket y sin EventSource: es una respuesta HTTP/2 que no se cierra, con flush_interval -1 en Caddy para que nada la bufferice por el camino. El mismo truco que usa el informe del detector de GEO en este sitio.

Hay una regla que en este montaje no puede disparar nunca, y prefiero decirlo antes que dejar el cero sin explicar: la de fuerza bruta. El puerto 22 no está abierto al mundo, solo escucha por la red privada, así que el único que autentica ahí soy yo. Ese feed sirve de línea base para el modelo, no de señal.

Mi opinión

Lo bueno:

  • Random Cut Forest funciona fuera de AWS sin trámites: dos jars que suman 421 KB, licencia Apache 2.0, y 21 microsegundos por evento en un servidor barato.
  • Flink aguanta el modelo adentro sin despeinarse. A 5.000 eventos por segundo, 86 milisegundos en el percentil 99 alcanzan de sobra para decidir sobre una transacción antes de autorizarla.
  • La combinación es donde está el valor real: 0.982 de recall, con las reglas explicables intactas para la auditoría y el modelo cubriendo el hueco.

Lo que no:

  • El modelo no sobrevive a un reinicio. Mientras el bosque viva fuera del estado de Flink, cada despliegue empieza ciego, y activar los checkpoints salva las reglas pero no el bosque.
  • No escala horizontalmente mientras sea un único bosque. Para pasar de 23 mil eventos por segundo hay que partir el modelo por segmento, y ahí vuelve el problema del calentamiento.
  • El umbral automático que trae la librería es demasiado generoso: 6.970 alertas sobre 101.668 eventos es un 6,9% del flujo, imposible de revisar a mano.
  • A 20.000 eventos por segundo la holgura real medida en vivo es del 12%, y con eso cualquier atasco de unos segundos tarda minutos en digerirse.

Conclusión: el modelo no reemplaza las reglas, llega antes que ellas: 418 de las 422 anomalías que solo él detecta son las dos primeras operaciones de una ráfaga, justo donde una regla de conteo todavía no tiene con qué disparar.

Cuándo lo usaría

  • En un flujo de hasta unos 20.000 eventos por segundo donde ya existan reglas maduras y aparezca el patrón nuevo que ninguna cubre. Ahí el bosque global paga su costo.
  • No lo pondría solo, sin reglas, en nada que tenga que explicarse ante un regulador: un puntaje de anomalía no es una justificación.
  • No armaría un modelo por cliente sin verificar antes cuántos eventos tiene cada uno. Bajo unos cientos por entidad, es plata tirada en memoria.

El montaje completo son cuatro piezas: un docker-compose con Kafka y Flink, un generador de eventos con semilla fija, el árbitro en Python de veinte líneas y tres clases Java. Los parámetros exactos de cada corrida están en las tablas de arriba, que es lo que hace falta para repetirla.


Fuentes primarias, todas verificadas contra la versión exacta que corrí:

Comentarios

Todavía no hay comentarios. El primero es tuyo.

Se revisa antes de publicarse. El correo no se guarda ni aparece en ninguna parte.