Articles / Data Systems

[ Data Systems ]Apache KafkaPythonanomaly detectionstreaming

Kafka Anomalies in Python: Models, Events, and Offsets

Run a local Kafka and Isolation Forest demo in Python with three partitions, event validation, and offset commits after processing succeeds.

April 2, 2026·9 min read
Kafka Anomalies in Python: Models, Events, and Offsets

A producer sends two-feature observations to transactions. Three consumers in the same group evaluate them with Isolation Forest and publish detected anomalies to anomalies. This local demo shows what Kafka acknowledges and when processing is committed; its synthetic data does not represent real financial transactions.

The original repository preserves the demonstration that inspired the article. This page's example pins versions, uses Kafka in KRaft mode, validates messages and bounds execution. Its scripts include changes from the repository. The images below belong to that original demonstration.

Architecture of the original demonstration

Local environment and three-partition topics

Tests use Windows, Python 3.13.5 and the direct dependencies pinned below. The client was installed in an isolated directory alongside the existing environment's other dependencies; a fresh virtualenv installation and a transitive lockfile were not checked. Python commands use the Windows executable; on Linux or macOS use .venv/bin/python instead.

Save the following as compose.yaml. Kafka 4.3.1 runs a broker and KRaft controller in one container, without ZooKeeper. The published port binds to the local machine. This demo uses PLAINTEXT, one replica and limited resources; a production service needs its own authentication, availability and capacity configuration. Official Apache Kafka image.

yaml
# compose.yaml
services:
  kafka:
    image: apache/kafka:4.3.1
    ports:
      - "127.0.0.1:9092:9092"
    environment:
      KAFKA_HEAP_OPTS: "-Xms256m -Xmx256m"
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: "broker,controller"
      KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
      KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://127.0.0.1:9092"
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
      KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
      KAFKA_CONTROLLER_QUORUM_VOTERS: "1@localhost:9093"
      KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT"
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
    mem_limit: 768m
    cpus: 1.0
powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install `
    confluent-kafka==2.16.0 scikit-learn==1.9.0 numpy==2.4.6 joblib==1.5.3
New-Item -ItemType Directory -Force model, streaming
docker compose up -d

Wait for Kafka to finish starting, then explicitly create both topics. Each command must complete successfully; if the broker is not ready, inspect docker compose logs kafka before continuing. The --describe output must show three partitions. Automatic topic creation is disabled.

powershell
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh `
    --bootstrap-server localhost:9092 --create --topic transactions `
    --partitions 3 --replication-factor 1
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh `
    --bootstrap-server localhost:9092 --create --topic anomalies `
    --partitions 3 --replication-factor 1
docker compose exec kafka /opt/kafka/bin/kafka-topics.sh `
    --bootstrap-server localhost:9092 --describe --topic transactions

A partition is an ordered sequence of messages, each with an offset within that partition. Kafka distributes a topic's partitions among consumers in the same group. Three consumers can work on these three partitions; adding more consumers leaves members without an assigned partition. Assignments may change during rebalancing: a thread's index does not bind it to a partition.

Train and save the model

Save this script as model/train.py. It generates 1,000 observations from two Gaussian clusters and saves the model next to the script, at the path the detector will use. contamination=0.01 determines a threshold from the training set; it does not measure real fraud prevalence or turn a score into a probability.

python
# model/train.py
from pathlib import Path

import numpy as np
from joblib import dump
from sklearn.ensemble import IsolationForest

rng = np.random.RandomState(42)
cluster = 0.3 * rng.randn(500, 2)
training = np.round(np.r_[cluster + 2, cluster - 2], 3)
model = IsolationForest(
    n_estimators=50, max_samples=500, random_state=42,
    contamination=0.01, n_jobs=1,
)
model.fit(training)
model_path = Path(__file__).resolve().parent / "isolation_forest.joblib"
dump(model, model_path)
print("Model saved:", model_path)

A joblib artifact can execute code when loaded. This demo loads only the model you just created in a trusted directory with compatible versions. Do not load files from an unknown source. Assessing quality requires representative data and independent evaluation of the threshold and detection errors.

Share confirmed publishing and event validation

Save streaming/kafka_io.py. The other two scripts import this module. Each send waits for its delivery callback and fails if Kafka does not confirm it within the stated deadline. Sending one event at a time makes acknowledgements visible without measuring or promising streaming throughput.

The consumer accepts a JSON object of at most 4 KiB with an ID and exactly one row of two finite numbers representable as float32. Invalid input raises an error and remains uncommitted; the demo stops so an operator can correct the source. A pipeline that continues after invalid data needs an explicit quarantine and retry policy.

python
# streaming/kafka_io.py
import json
import math

import numpy as np
from confluent_kafka import KafkaException, Producer

BOOTSTRAP = "127.0.0.1:9092"
TRANSACTIONS_TOPIC = "transactions"
ANOMALIES_TOPIC = "anomalies"
PARTITIONS = 3


def make_producer():
    return Producer({
        "bootstrap.servers": BOOTSTRAP,
        "enable.idempotence": True,
        "delivery.timeout.ms": 5000,
    })


def publish(producer, topic, record, partition=-1):
    delivery = {}

    def acknowledged(error, message):
        delivery["error"] = error

    producer.produce(
        topic, key=record["id"],
        value=json.dumps(record, allow_nan=False).encode("utf-8"),
        partition=partition, on_delivery=acknowledged,
    )
    if producer.flush(5) or "error" not in delivery:
        raise TimeoutError("Event delivery was not confirmed.")
    if delivery["error"] is not None:
        raise KafkaException(delivery["error"])


def decode_event(payload):
    if not isinstance(payload, bytes) or len(payload) > 4096:
        raise ValueError("Expected a JSON event of at most 4096 bytes.")
    try:
        record = json.loads(payload.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error:
        raise ValueError("Invalid event JSON.") from error
    if not isinstance(record, dict):
        raise ValueError("Expected an event object.")
    event_id = record.get("id")
    if not isinstance(event_id, str) or not 1 <= len(event_id) <= 80:
        raise ValueError("Expected a nonempty event id of at most 80 characters.")
    data = record.get("data")
    if not isinstance(data, list) or len(data) != 1:
        raise ValueError("Expected one observation.")
    row = data[0]
    if not isinstance(row, list) or len(row) != 2:
        raise ValueError("Expected two features.")
    limit = float(np.finfo(np.float32).max)
    for value in row:
        if type(value) not in (int, float) or abs(value) > limit or not math.isfinite(value):
            raise ValueError("Features must be finite numbers representable as float32.")
    return record, np.asarray(data, dtype=np.float64)

Publish a finite sample

Save streaming/producer.py. It sends 60 events across the three partitions, with a distinct batch ID and a UTC timestamp. The uniform branch is selected with probability 0.05; some of those points may look normal to the model. Nor is every Gaussian point assumed to be classified as normal.

python
# streaming/producer.py
from datetime import datetime, timezone
from uuid import uuid4
import time

import numpy as np
from kafka_io import PARTITIONS, TRANSACTIONS_TOPIC, make_producer, publish


def produce_events(count=60):
    rng = np.random.default_rng(42)
    batch = uuid4().hex
    with make_producer() as producer:
        for index in range(count):
            if rng.random() < 0.05:
                observation = rng.uniform(-4, 4, size=2)
            else:
                observation = 0.3 * rng.normal(size=2) + rng.choice([-2, 2])
            record = {
                "id": f"{batch}-{index}",
                "data": [np.round(observation, 3).tolist()],
                "current_time": datetime.now(timezone.utc).isoformat(),
            }
            publish(producer, TRANSACTIONS_TOPIC, record, partition=index % PARTITIONS)
            print("SENT", record["id"], flush=True)
            time.sleep(0.1)


if __name__ == "__main__":
    produce_events()

Producer in the original demonstration

Detect and commit after processing

Save streaming/anomalies_detector.py. Each thread creates its own consumer and producer; the three consumers share a group. Automatic commits and automatic offset storage are disabled. For an anomaly, output delivery is confirmed before the input offset is committed. Normal events are committed after evaluation. Before subscribing, each consumer checks connectivity and the three partitions with a five-second timeout. This demo stops consumption on a global client error received during polling, even when Kafka could retry; delivery, input and commit errors also propagate. Contexts close clients and the stop event ends the other threads.

python
# streaming/anomalies_detector.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from threading import Event
import time

from joblib import load
from confluent_kafka import Consumer, KafkaException
from kafka_io import (
    ANOMALIES_TOPIC, BOOTSTRAP, PARTITIONS, TRANSACTIONS_TOPIC,
    decode_event, make_producer, publish,
)

model_path = Path(__file__).resolve().parents[1] / "model/isolation_forest.joblib"


def detect(stop, seconds=30, group_id="anomaly-detector-group"):
    model = load(model_path)
    errors = []
    config = {
        "bootstrap.servers": BOOTSTRAP,
        "group.id": group_id,
        "auto.offset.reset": "earliest",
        "enable.auto.commit": False,
        "enable.auto.offset.store": False,
        "allow.auto.create.topics": False,
        "socket.timeout.ms": 5000,
        "error_cb": errors.append,
    }
    with Consumer(config) as consumer:
        metadata = consumer.list_topics(topic=TRANSACTIONS_TOPIC, timeout=5)
        topic = metadata.topics[TRANSACTIONS_TOPIC]
        if topic.error is not None:
            raise KafkaException(topic.error)
        if len(topic.partitions) != PARTITIONS:
            raise ValueError("Create the input topic with three partitions first")
        with make_producer() as producer:
            consumer.subscribe([TRANSACTIONS_TOPIC])
            deadline = time.monotonic() + seconds
            while not stop.is_set() and time.monotonic() < deadline:
                message = consumer.poll(1)
                if errors:
                    raise KafkaException(errors[0])
                if message is None:
                    continue
                if message.error():
                    raise KafkaException(message.error())
                record, data = decode_event(message.value())
                if model.predict(data)[0] == -1:
                    record["score"] = float(model.score_samples(data)[0])
                    publish(producer, ANOMALIES_TOPIC, record)
                    print("ANOMALY", record["id"], record["score"], flush=True)
                committed = consumer.commit(message=message, asynchronous=False)
                for partition in committed:
                    if partition.error is not None:
                        raise KafkaException(partition.error)


def run(seconds=30):
    stop = Event()
    with ThreadPoolExecutor(max_workers=PARTITIONS) as workers:
        futures = [workers.submit(detect, stop, seconds) for _ in range(PARTITIONS)]
        try:
            for future in as_completed(futures):
                future.result()
        finally:
            stop.set()


if __name__ == "__main__":
    run()

Anomalies in the original demonstration

A failure after publishing an anomaly but before committing can cause the input to reappear and produce another output copy. The ID is preserved so a downstream consumer can identify duplicates; this example implements neither persistent deduplication nor a transaction across the two topics. Producer idempotence does not remove duplicates caused by reprocessing an input. Python client offsets and delivery guarantees.

Concurrent consumers in the original demonstration

Run, inspect, and stop

After creating the four files, train first. In two terminals in the same folder, start the detector and immediately start the producer. Each consumer processes for 30 seconds after its connectivity check; startup and shutdown add time. If you start the producer late, run the detector again to process pending messages. The producer finishes after 60 events. The detector also finishes at its deadline or on error; Ctrl+C activates thread shutdown. The console prints SENT for confirmed deliveries and ANOMALY with each published detection's ID and score.

powershell
# First train the model from the project folder.
.\.venv\Scripts\python.exe model/train.py

# Terminal 1: three consumers; each processes for 30 seconds after startup.
.\.venv\Scripts\python.exe streaming/anomalies_detector.py

# Terminal 2: publish 60 events while the detector is running.
.\.venv\Scripts\python.exe streaming/producer.py

Inspect output messages and stop the broker when finished. The console consumer exits after its no-message timeout; that timeout does not prove anomalies were present. The demo measures neither end-to-end latency, production capacity nor quality on real data.

powershell
docker compose exec kafka /opt/kafka/bin/kafka-console-consumer.sh `
    --bootstrap-server localhost:9092 --topic anomalies --from-beginning `
    --timeout-ms 5000
docker compose down

For request-driven inference, continue with FastAPI and Docker. To examine changes in data and model results, see monitoring with Evidently.

Are you building an AI or data platform?

If you need to adapt this approach to your system, tell me about your data and the technical decision you need to resolve.

View all projects