File size: 3,383 Bytes
36f8b1f
a97c8f9
 
 
 
 
 
c747422
a97c8f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c747422
a97c8f9
 
 
 
 
 
 
 
 
c747422
a97c8f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36f8b1f
a97c8f9
0d07988
 
a97c8f9
 
0d07988
c747422
 
 
 
 
a97c8f9
 
c747422
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from datasets import load_dataset
from model_setup import predict_batch
import numpy as np
import json
from pathlib import Path
import mlflow
import sys
from datetime import date

WINDOW_SIZE = 500          # tweets per monitoring window, one "day" of traffic
DRIFT_THRESHOLD = 0.10     # alert when the negative share grows by more than 10 points
BASELINE_PATH = Path("baseline.json")
BASELINE_WINDOWS = 4       # first windows of the stream define normal behaviour
MLFLOW_URI = "sqlite:///mlflow.db"



def get_window(index, size=WINDOW_SIZE):
    """Return one window of incoming texts. Swap this to read from a live source."""
    test = load_dataset("cardiffnlp/tweet_eval", "sentiment")["test"]
    start = index * size
    return test["text"][start:start + size]


def sentiment_distribution(texts):
    """Share of negative, neutral and positive predictions over a list of texts."""
    predictions = predict_batch(texts)
    counts = np.bincount(predictions, minlength=3)
    return (counts / len(predictions)).tolist()

def build_baseline():
    """Compute the reference distribution on the first windows of the stream and store it."""
    texts = []
    for index in range(BASELINE_WINDOWS):
        texts += get_window(index)

    distribution = sentiment_distribution(texts)
    BASELINE_PATH.write_text(json.dumps(distribution))
    return distribution

def load_baseline():
    """Read the stored baseline, building it the first time."""
    if BASELINE_PATH.exists():
        return json.loads(BASELINE_PATH.read_text())
    return build_baseline()


def check_drift(current, baseline, threshold=DRIFT_THRESHOLD):
    """Compare the negative share against the baseline; return the shift and whether it alerts."""
    shift = current[0] - baseline[0]  # index 0 is the negative class
    return shift, shift > threshold


def monitor(first_window, n_windows):
    """Run the monitoring loop over consecutive windows; return the alerting ones."""
    baseline = load_baseline()
    print(f"{'baseline negative share:':<26}{baseline[0]:>8.2%}\n")

    header = f"{'window':<10}{'negative':>10}{'neutral':>10}{'positive':>10}{'shift':>10}"
    print(header)
    print("-" * len(header))

    alerts = []
    for index in range(first_window, first_window + n_windows):
        distribution = sentiment_distribution(get_window(index))
        shift, alerting = check_drift(distribution, baseline)

        mlflow.log_metrics({
            "negative": distribution[0],
            "neutral": distribution[1],
            "positive": distribution[2],
            "negative_shift": shift,
            "alert": int(alerting),
        }, step=index)

        flag = "  ALERT" if alerting else ""
        print(f"{index:<10}{distribution[0]:>10.2%}{distribution[1]:>10.2%}"
              f"{distribution[2]:>10.2%}{shift:>+10.2%}{flag}")
        if alerting:
            alerts.append(index)

    return alerts


if __name__ == "__main__":
    mlflow.set_tracking_uri(MLFLOW_URI)

    # walk the stream with the calendar, so a scheduled run sees new data each day
    total_windows = 24
    available = total_windows - BASELINE_WINDOWS
    first = BASELINE_WINDOWS + (date.today().toordinal() % available)

    with mlflow.start_run():
        mlflow.log_params({"window_size": WINDOW_SIZE, "threshold": DRIFT_THRESHOLD})
        alerts = monitor(first_window=first, n_windows=1)