Ćwiczenia 3: Powtórka + Isolation Forest

Studia zaoczne | 1,5h

Cel

  • Utrwalenie pipeline’u Kafka → ML → alerty z Ćw. 1–2,
  • Zrozumienie ograniczeń uczenia nadzorowanego (Random Forest),
  • Wytrenowanie modelu nienadzorowanego (Isolation Forest),
  • Wymiana modelu w istniejącym API bez zmiany interfejsu.

Kluczowa różnica: Random Forest potrzebuje etykiet (wiemy co jest fraudem). Isolation Forest trenuje się wyłącznie na normalnych transakcjach — wykrywa to, co odbiega od normy, bez wcześniejszej wiedzy o fraudach.


Część 1: Powtórka (15 min)

Zanim zaczniemy nowy materiał, upewnijmy się że środowisko działa i pamiętamy kontekst.

Zadanie 1.1 — Sprawdź środowisko

W terminalu JupyterLab:

# Temat 'transactions' powinien istnieć
kafka/bin/kafka-topics.sh --list --bootstrap-server broker:9092

# Uruchom producenta z Ćw. 1
python producer.py &
import requests

# Sprawdź czy API z Ćw. 2 nadal działa
# Jeśli nie: uruchom w terminalu: uvicorn fraud_api:app --host 0.0.0.0 --port 8001

try:
    r = requests.get('http://localhost:8001/health', timeout=2)
    print('API działa:', r.json())
except Exception as e:
    print('API niedostępne — uruchom uvicorn w terminalu:', e)

Zadanie 1.2 — Pytania kontrolne

# Odpowiedz w komentarzach:
#
# 1. Jakie 3 cechy ma model z Ćw. 2?
#    ODPOWIEDŹ:
#
# 2. Co zwraca endpoint POST /score?
#    ODPOWIEDŹ:
#
# 3. Dlaczego w ml_consumer.py używamy tx_per_minute=5 (stała)?
#    ODPOWIEDŹ:
#
# 4. Co by się stało gdyby dwa procesy ml_consumer.py miały ten sam group_id?
#    ODPOWIEDŹ:

Część 2: Ograniczenia Random Forest (10 min)

Model z Ćw. 2 działa świetnie na danych syntetycznych — ale w prawdziwym życiu jest problem:

# Problem: Random Forest to uczenie NADZOROWANE
# Potrzebuje etykiet: które transakcje są fraudami?
#
# W rzeczywistości:
# - Etykiety zbieramy tygodniami/miesiącami
# - Nowe rodzaje fraudów nie mają etykiet
# - Frauderzy adaptują swoje zachowanie
#
# Rozwiązanie: uczenie NIENADZOROWANE
# Trenujemy model tylko na NORMALNYCH transakcjach.
# Każda transakcja mocno odbiegająca od normy = podejrzana.

print("Uczenie nadzorowane (RF):")
print("  Dane treningowe: 2000 normalnych + 100 fraudów")
print("  Model uczy się: 'to jest fraud, to nie jest'")
print()
print("Uczenie nienadzorowane (IF):")
print("  Dane treningowe: 2000 normalnych (fraudy niepotrzebne!)")
print("  Model uczy się: 'jak wygląda normalna transakcja'")
print("  Na żywo: 'ta transakcja NIE wygląda normalnie'")

Część 3: Isolation Forest (30 min)

Jak działa Isolation Forest?

Algorytm buduje losowe drzewa decyzyjne. Kluczowa obserwacja:

  • Punkty odstające (anomalie) są łatwe do izolacji — potrzeba niewielu podziałów
  • Punkty normalne są “gęste” — potrzeba wielu podziałów żeby je wyizolować
Normalna transakcja:          Anomalia (fraud):
amount=150, is_elec=0         amount=4800, is_elec=1

[amount < 2000?]              [amount < 2000?]
  → TAK → [is_elec < 0.5?]     → NIE → IZOLOWANA (2 kroki)
     → TAK → [user_id?] ...
     (wiele kroków)

contamination — spodziewany odsetek anomalii w danych (~5% dla naszego przypadku).

Zadanie 3.1 — Przygotuj dane (tylko normalne transakcje)

import pandas as pd
import numpy as np

np.random.seed(42)

# Generujemy TYLKO normalne transakcje — fraudów nie potrzebujemy!
N_NORMAL = 2000

normal = pd.DataFrame({
    'amount':        np.random.lognormal(5, 1, N_NORMAL).clip(5, 5000),
    'is_electronics': np.random.binomial(1, 0.3, N_NORMAL),
    'tx_per_minute':  np.random.poisson(3, N_NORMAL),
})

print(f"Dane treningowe: {len(normal)} normalnych transakcji")
print()
normal.describe().round(2)

Zadanie 3.2 — Wytrenuj Isolation Forest

from sklearn.ensemble import IsolationForest
import pickle

features = ['amount', 'is_electronics', 'tx_per_minute']
X_train = normal[features]

# contamination: spodziewamy się ~5% anomalii w strumieniu
iso_forest = IsolationForest(
    n_estimators=100,
    contamination=0.05,
    random_state=42
)
iso_forest.fit(X_train)

print("Model wytrenowany.")
print(f"Liczba drzew: {iso_forest.n_estimators}")
print(f"Contamination: {iso_forest.contamination}")

# Zapisz model
with open('fraud_model_if.pkl', 'wb') as f:
    pickle.dump(iso_forest, f)
print("\nZapisano do fraud_model_if.pkl")

Zadanie 3.3 — Przetestuj model

Uwaga: predict() zwraca +1 (normalna) lub -1 (anomalia) — odwrotnie niż RF!

decision_function() zwraca anomaly score: im bardziej ujemny, tym bardziej podejrzany.

test_cases = pd.DataFrame([
    {'amount': 150.0,  'is_electronics': 0, 'tx_per_minute': 3,  'opis': 'normalna'},
    {'amount': 89.0,   'is_electronics': 0, 'tx_per_minute': 2,  'opis': 'normalna'},
    {'amount': 4800.0, 'is_electronics': 1, 'tx_per_minute': 12, 'opis': 'podejrzana'},
    {'amount': 3500.0, 'is_electronics': 1, 'tx_per_minute': 9,  'opis': 'podejrzana'},
    {'amount': 250.0,  'is_electronics': 1, 'tx_per_minute': 5,  'opis': 'graniczna'},
])

X_test = test_cases[features]
preds  = iso_forest.predict(X_test)          # +1 lub -1
scores = iso_forest.decision_function(X_test) # anomaly score

test_cases['wynik']  = preds
test_cases['score']  = scores.round(4)
test_cases['anomalia'] = test_cases['wynik'] == -1

print(test_cases[['opis', 'amount', 'is_electronics', 'tx_per_minute',
                   'wynik', 'score', 'anomalia']].to_string(index=False))

Zadanie 3.4 — Porównaj RF vs IF na tych samych danych

# Załaduj stary model RF z Ćw. 2
with open('fraud_model.pkl', 'rb') as f:
    rf_model = pickle.load(f)

# Wygeneruj mieszane dane testowe (normalne + fraudy)
np.random.seed(0)
test_normal = pd.DataFrame({
    'amount':        np.random.lognormal(5, 1, 50).clip(5, 3000),
    'is_electronics': np.random.binomial(1, 0.3, 50),
    'tx_per_minute':  np.random.poisson(3, 50),
    'true_label': 0
})
test_fraud = pd.DataFrame({
    'amount':        np.random.uniform(2000, 9000, 10),
    'is_electronics': np.random.binomial(1, 0.7, 10),
    'tx_per_minute':  np.random.poisson(8, 10),
    'true_label': 1
})
test_df = pd.concat([test_normal, test_fraud], ignore_index=True)
X_cmp   = test_df[features]

# Predykcje
rf_pred = rf_model.predict(X_cmp)                  # 0 lub 1
if_pred = (iso_forest.predict(X_cmp) == -1).astype(int)  # 0 lub 1

from sklearn.metrics import precision_score, recall_score, f1_score

y_true = test_df['true_label']

print(f"{'Model':<20} {'Precision':>10} {'Recall':>10} {'F1':>10}")
print("-" * 55)
for name, pred in [("Random Forest", rf_pred), ("Isolation Forest", if_pred)]:
    p = precision_score(y_true, pred, zero_division=0)
    r = recall_score(y_true, pred, zero_division=0)
    f = f1_score(y_true, pred, zero_division=0)
    print(f"{name:<20} {p:>10.3f} {r:>10.3f} {f:>10.3f}")

print()
print("Uwaga: dane testowe są syntetyczne — IF może wypaść gorzej bo nie widział")
print("fraudów podczas treningu. W produkcji (bez etykiet) IF jest jedyną opcją.")

Część 4: Zaktualizuj API (20 min)

Zamieniamy model w istniejącym API. Interfejs (pola wejściowe, struktura odpowiedzi) pozostaje identyczny — to kluczowa zasada inżynierii ML: wymiana modelu nie powinna wymagać zmian w konsumentach API.

Zadanie 4.1 — Nowy fraud_api.py z Isolation Forest

%%file fraud_api.py
from fastapi import FastAPI
from pydantic import BaseModel
import pickle
import numpy as np

app = FastAPI(title="Fraud Detection API — Isolation Forest")

model = pickle.load(open('fraud_model_if.pkl', 'rb'))

class Transaction(BaseModel):
    amount: float
    is_electronics: int
    tx_per_minute: int

@app.post("/score")
def score(tx: Transaction):
    X = np.array([[tx.amount, tx.is_electronics, tx.tx_per_minute]])
    prediction     = model.predict(X)[0]           # +1 lub -1
    anomaly_score  = model.decision_function(X)[0]  # ujemny = bardziej podejrzany

    # Normalizujemy score do przedziału [0, 1] — dla spójności z Ćw. 2
    # decision_function typowo zwraca wartości z zakresu [-0.5, 0.5]
    fraud_probability = float(np.clip(0.5 - anomaly_score, 0.0, 1.0))

    return {
        "is_fraud":          bool(prediction == -1),
        "fraud_probability": round(fraud_probability, 4),
        "model":             "isolation_forest",
    }

@app.get("/health")
def health():
    return {"status": "ok"}

Zrestartuj serwer w terminalu:

# Ctrl+C (jeśli działał) a potem:
uvicorn fraud_api:app --host 0.0.0.0 --port 8001 --reload

Zadanie 4.2 — Przetestuj nowe API

import requests, time

time.sleep(1)  # daj chwilę na restart serwera

cases = [
    {"amount": 150,  "is_electronics": 0, "tx_per_minute": 3,  "opis": "normalna"},
    {"amount": 4800, "is_electronics": 1, "tx_per_minute": 12, "opis": "podejrzana"},
    {"amount": 89,   "is_electronics": 0, "tx_per_minute": 2,  "opis": "normalna"},
    {"amount": 3200, "is_electronics": 1, "tx_per_minute": 8,  "opis": "podejrzana"},
]

for case in cases:
    payload = {k: v for k, v in case.items() if k != 'opis'}
    r = requests.post("http://localhost:8001/score", json=payload)
    result = r.json()
    print(f"[{case['opis']:10s}] amount={case['amount']:5} "
          f"→ fraud={result['is_fraud']}, prob={result['fraud_probability']:.3f}")

Część 5: Podłącz do Kafki (15 min)

Konsument ML z Ćw. 2 działa bez zmian — zmienił się tylko model za API.

Zadanie 5.1 — Uruchom ml_consumer.py i obserwuj różnice

W dwóch terminalach:

# Terminal 1: python producer.py
# Terminal 2: python ml_consumer.py
#
# ml_consumer.py z Ćw. 2 działa bez zmian — API ma ten sam interfejs.
#
# Obserwuj: czy IF flaguje inne transakcje niż RF?
# Szczególnie: transakcje o średniej kwocie ale rzadkiej kategorii?

print("Uruchom w terminalach i porównaj wyniki z Ćw. 2.")

Zadanie 5.2 — Pytania dyskusyjne

# 1. Jakie transakcje IF flaguje, a RF nie (i odwrotnie)?
#    OBSERWACJA:

# 2. Czy parametr contamination=0.05 ma wpływ na liczbę alertów?
#    Co by się stało gdybyś zmienił go na 0.01?
#    ODPOWIEDŹ:

# 3. Jaką zaletę ma IF w systemie produkcyjnym gdzie fraudy są nowe i nieznane?
#    ODPOWIEDŹ:

Praca domowa

  1. Zmień contamination na 0.01 i 0.10 — jak zmienia się liczba alertów? Dlaczego?
  2. Dodaj do API endpoint GET /model-info zwracający {"type": "isolation_forest", "n_estimators": ..., "contamination": ...}.
  3. Uruchom równocześnie konsumenta z modelem RF i IF (różne group_id). Porównaj alerty.

Następne zajęcia: Apache Spark — przetwarzanie strumieniowe na dużą skalę.

# Sprawdź pliki które masz po tych ćwiczeniach:
import os
for f in ['fraud_model.pkl', 'fraud_model_if.pkl', 'fraud_api.py', 'ml_consumer.py']:
    exists = os.path.exists(f)
    print(f"{'✓' if exists else '✗'} {f}")