
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.
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.
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étrica | Reglas | Random Cut Forest | Ambos |
|---|---|---|---|
| Recall | 0.814 | 0.943 | 0.982 |
| Precisión | 0.182 | 0.339 | 0.162 |
| Eventos marcados | 11.198 | 6.970 | 15.176 |
| Costo del operador, mediana | 1,8 µs | 21 µs | 23 µs |
| Costo del operador, media | 3,4 µs | 41,5 µs | 44,9 µs |
| Costo del operador, percentil 99 | 14,5 µs | 528 µs | 540 µs |
| Throughput con paralelismo 1 | 173.297 ev/s | — | 23.533 ev/s |
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 entrada | Mediana | Percentil 95 | Percentil 99 | Máximo |
|---|---|---|---|---|
| 5.000 ev/s | 47 ms | 76 ms | 86 ms | 134 ms |
| 20.000 ev/s, últimos 30 s de 2 minutos | 5.834 ms | 7.894 ms | 8.137 ms | 8.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.
21% de la capacidad medida. El pipeline va al día.
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.
- Arranque exigido
- 64 operaciones
- Mediana por tarjeta
- 51 operaciones
- Tarjetas que llegan
- 96 de 2.000 · 4,8%
Mismos árboles, misma semilla, mismas variables. Lo único que cambia es cuántas operaciones ve cada modelo.
Tres umbrales con estado. El techo del pipeline sin modelo.
El bosque cuesta 7,36 veces el throughput.
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.
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í:
- Notas de la versión de Apache Flink 2.3 y el artefacto 2.3.0 en el archivo de Apache.
- Random Cut Forest 4.4.0-java, licencia Apache 2.0.
- flink-connector-kafka 5.0.0-2.2 en Maven Central, el que exige declarar
flink-connector-basea mano. - Notas de la versión de Apache Kafka 4.3.1.
Comentarios
Todavía no hay comentarios. El primero es tuyo.