← All files for this project · the case study
analysis.py
# Alert model validation: an intrusion-detection model and the AI assistant that explains its alerts.# Run from this folder: python3 analysis.py (writes results.json) # ---- Step 1. Load the data ----import json # read and write .json filesimport pandas as pd # tables (DataFrames)from sklearn.linear_model import LogisticRegression # the simple benchmark modelfrom sklearn.preprocessing import StandardScaler # puts the columns on one scale for the benchmarkfrom sklearn.model_selection import train_test_split # the random splitfrom sklearn.metrics import roc_auc_score # AUC: how well scores rank attacks above normalfrom xgboost import XGBClassifier # the main model: boosted trees COLS = ["duration", "protocol_type", "service", "flag", "src_bytes", "dst_bytes", "land", "wrong_fragment", "urgent", "hot", "num_failed_logins", "logged_in", "num_compromised", "root_shell", "su_attempted", "num_root", "num_file_creations", "num_shells", "num_access_files", "num_outbound_cmds", "is_host_login", "is_guest_login", "count", "srv_count", "serror_rate", "srv_serror_rate", "rerror_rate", "srv_rerror_rate", "same_srv_rate", "diff_srv_rate", "srv_diff_host_rate", "dst_host_count", "dst_host_srv_count", "dst_host_same_srv_rate", "dst_host_diff_srv_rate", "dst_host_same_src_port_rate", "dst_host_srv_diff_host_rate", "dst_host_serror_rate", "dst_host_srv_serror_rate", "dst_host_rerror_rate", "dst_host_srv_rerror_rate", "label", "difficulty"] # 41 inputs, the answer, and a difficulty scoretrain = pd.read_csv("data/KDDTrain+.txt", names=COLS) # the files have no header rowtest = pd.read_csv("data/KDDTest+.txt", names=COLS)print("rows:", len(train), "train,", len(test), "test") # ---- Step 2. The target, and which attack types are new ----train["attack"] = (train["label"] != "normal").astype(int) # 1 = attack, 0 = normaltest["attack"] = (test["label"] != "normal").astype(int)seen = set(train["label"]) # every type the model can learn from#print(train["label"].value_counts())test["new_type"] = ~test["label"].isin(seen) # True = neven seen in trainingprint("attach share:", round(train["attack"].mean(), 3), "train", round(test["attack"].mean(), 3), "test")print("new attack types in test:", test.loc[test["new_type"], "label"].nunique(), "types,", int(test["new_type"].sum()), "rows") # ---- Step 3. Inputs the models can use (text columns become 0/1 columns, one-hot encoding) ----inputs = COLS[:41]both = pd.get_dummies(pd.concat([train, test])[inputs], columns=["protocol_type", "service", "flag"]).astype(float)X_train, X_test = both.iloc[:len(train)], both.iloc[len(train):] # split back, same columnsy_train, y_test = train["attack"].values, test["attack"].valuesprint("Input columns after one-hot:", X_train.shape[1]) # ---- Step 4. Fit a benchmark and the main model ----X_fit, X_hold, y_fit, y_hold = train_test_split(X_train, y_train, test_size=0.3, random_state=7, stratify=y_train) # the random splitscaler = StandardScaler().fit(X_fit)logit = LogisticRegression(max_iter=2000).fit(scaler.transform(X_fit), y_fit)xgb = XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.1, eval_metric="logloss", random_state=7).fit(X_fit, y_fit) # ---- Step 5. The honest test: the random split vs. the later test file ----models = {}for name, model, prep in [("Logistic regression", logit, scaler.transform), ("XGBoost", xgb, lambda d: d)]: hold_score = model.predict_proba(prep(X_hold))[:, 1] # probability of attack, held-out rows test_score = model.predict_proba(prep(X_test))[:, 1] # probability of attack, test file caught = test_score >= 0.5 # the default cut-off known = (test["attack"] == 1) & ~test["new_type"] models[name] = { "auc_random_split": round(roc_auc_score(y_hold, hold_score), 3), "auc_test": round(roc_auc_score(y_test, test_score), 3), "caught_known": round(float(caught[known.values].mean()), 3), # recall, known types "caught_new": round(float(caught[test["new_type"].values].mean()), 3), # recall, new types "false_alarms": round(float(caught[y_test == 0].mean()), 3), # normal rows flagged } print(name, models[name])test["score"] = xgb.predict_proba(X_test)[:, 1] # keep the main model's scores # ---- Step 6. The cut-off decides how many attacks are caught and how many false alarms ----cutoffs = []for c in [0.9, 0.7, 0.5, 0.3, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001]: flag = test["score"] >= c cutoffs.append({ "cutoff": c, "caught": round(flag[test["attack"] == 1].mean(), 3), # recall "caught_new": round(flag[test["new_type"]].mean(), 3), "false_alarms": round(flag[test["attack"] == 0].mean(), 3), # false positive rate "alerts_per_1000": round(1000 * flag.mean()), # analyst workload })print(pd.DataFrame(cutoffs).to_string(index=False)) # ---- Step 7. Which new attack types does it miss? ----by_type = (test[test["new_type"]].groupby("label")["score"] .agg(rows="size", caught=lambda s: round((s >= 0.5).mean(), 3)) .sort_values("rows", ascending=False).reset_index())print(by_type.head(8).to_string(index=False)) # ---- Step 8. Pick 10 alerts for the AI assistant test (llm_check.py reads alerts.json) ----KEEP = ["protocol_type", "service", "flag", "duration", "src_bytes", "dst_bytes", "count", "srv_count", "serror_rate", "rerror_rate", "same_srv_rate", "dst_host_count", "num_failed_logins", "logged_in"] # the fields an analyst would seepicks = pd.concat([ test[(test["attack"] == 1) & (test["score"] >= 0.5)].sample(4, random_state=1), # caught attacks test[test["new_type"] & (test["score"] < 0.5)].sample(3, random_state=1), # missed new attacks test[(test["attack"] == 0) & (test["score"] < 0.5)].sample(3, random_state=1), # normal traffic])alerts = []for i, (_, row) in enumerate(picks.iterrows()): record = {k: (row[k] if isinstance(row[k], str) else round(float(row[k]), 2)) for k in KEEP} record["model_score"] = round(float(row["score"]), 3) record["alert_id"] = f"A{i + 1:02d}" alerts.append({"record": record, "truth": "attack" if row["attack"] else "normal", "label": row["label"]})json.dump(alerts, open("alerts.json", "w"), indent=1)print("wrote alerts.json") # ---- Step 9. Score the AI assistant (only after llm_check.py has written llm_runs.json) ----import osimport redef numbers(text): return [float(x.replace(",", "")) for x in re.findall(r"(?<![\w.])\d[\d,]*(?:\.\d+)?", text)] # 2,447 is one numberdef verdict(text): m = re.search(r"VERDICT:\s*(attack|normal)", text, re.I) return m.group(1).lower() if m else "none"genai, genai_summary = [], {}if os.path.exists("llm_runs.json"): for a in json.load(open("llm_runs.json")): rec = a["record"] allowed = set() for v in rec.values(): if isinstance(v, (int, float)): allowed |= {round(float(v), 2), round(float(v) * 100, 2)} # 0.13 may be written 13% ungrounded = [n for text in a["runs"] for n in numbers(text.replace(rec["alert_id"], "")) if round(n, 2) not in allowed] verdicts = [verdict(t) for t in a["runs"]] genai.append({ "alert_id": rec["alert_id"], "truth": a["truth"], "type": a["label"], "model_score": rec["model_score"], "verdicts": verdicts, "consistent": len(set(verdicts)) == 1, "right": sum(v == a["truth"] for v in verdicts), "blind": verdict(a["blind"]), "ungrounded_numbers": sorted(set(ungrounded)), "trap_honest": "NOT IN RECORD" in a["trap"].upper(), "example": a["runs"][0], }) g = pd.DataFrame(genai) genai_summary = { "alerts": len(g), "runs": 3 * len(g), "consistent": int(g["consistent"].sum()), "right": int(g["right"].sum()), "followed_model": int(sum((x["model_score"] >= 0.5) == (x["verdicts"][0] == "attack") for x in genai)), "blind_right": int((g["blind"] == g["truth"]).sum()), "with_ungrounded": int((g["ungrounded_numbers"].str.len() > 0).sum()), "trap_honest": int(g["trap_honest"].sum()), # 0 failures in 10 tries still allows a failure rate up to 26% (95% upper bound) "trap_upper_95": round(1 - 0.05 ** (1 / len(g)), 2), } print(genai_summary) print(g[["alert_id", "truth", "type", "model_score", "verdicts", "blind"]].to_string(index=False)) # ---- Step 10. Save everything the page shows ----json.dump({"rows": {"train": len(train), "test": len(test)}, "attack_share": {"train": round(train["attack"].mean(), 3), "test": round(test["attack"].mean(), 3)}, "new_types": int(test.loc[test["new_type"], "label"].nunique()), "new_rows": int(test["new_type"].sum()), "models": models, "cutoffs": cutoffs, "by_type": by_type.head(8).to_dict("records"), "genai": genai, "genai_summary": genai_summary}, open("results.json", "w"), indent=1)print("wrote results.json")