19 KiB
19 KiB
In [ ]:
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
DATA_PATH = Path("data/ctu13_botnet_flows.csv")
OUTPUT_DIR = Path("output")
OUTPUT_DIR.mkdir(exist_ok=True)
if not DATA_PATH.exists():
raise FileNotFoundError(
f"Missing {DATA_PATH}. Follow data/README.md and run "
"python data/prepare_ctu13.py <path-to-scenario.binetflow>"
)
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.metrics import (
ConfusionMatrixDisplay, accuracy_score, average_precision_score,
confusion_matrix, f1_score, precision_score, recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
RANDOM_STATE = 42In [ ]:
flows = pd.read_csv(DATA_PATH, low_memory=False, parse_dates=["start_time"])
expected_families = {"Botnet", "Normal", "Background"}
print("Shape:", flows.shape)
display(flows.head(3))
display(flows["label_family"].value_counts().rename("flows").to_frame())
print("Raw label examples:")
for value in flows["raw_label"].dropna().drop_duplicates().head(8):
print(" -", value)In [ ]:
categorical_features = ["protocol", "direction", "state"]
numeric_features = [
"duration", "src_port", "dst_port", "src_tos", "dst_tos",
"total_packets", "total_bytes", "src_bytes", "dst_bytes",
"bytes_per_packet", "src_byte_ratio", "start_hour",
"is_well_known_dst_port",
]
feature_columns = categorical_features + numeric_features
labeled = flows.loc[flows["label_family"].isin(["Botnet", "Normal"])].copy()
background = flows.loc[flows["label_family"].eq("Background")].copy()
X = labeled[feature_columns]
y = labeled["binary_label"].astype(int)
X_train_valid, X_test, y_train_valid, y_test = train_test_split(
X, y, test_size=0.20, stratify=y, random_state=RANDOM_STATE
)
X_train, X_valid, y_train, y_valid = train_test_split(
X_train_valid, y_train_valid, test_size=0.25,
stratify=y_train_valid, random_state=RANDOM_STATE,
)
print("Train / validation / test:", len(X_train), len(X_valid), len(X_test))
print("Background records reserved for triage:", len(background))In [ ]:
preprocessor = ColumnTransformer([
("categorical", Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("encoder", OneHotEncoder(handle_unknown="ignore")),
]), categorical_features),
("numeric", SimpleImputer(strategy="median"), numeric_features),
])
detector = Pipeline([
("preprocessor", preprocessor),
("model", RandomForestClassifier(
n_estimators=200,
max_depth=12,
min_samples_leaf=3,
class_weight="balanced_subsample",
random_state=RANDOM_STATE,
n_jobs=-1,
)),
])
detector.fit(X_train, y_train)
validation_scores = detector.predict_proba(X_valid)[:, 1]In [ ]:
threshold_rows = []
for threshold in np.linspace(0.05, 0.95, 91):
predictions = (validation_scores >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_valid, predictions, labels=[0, 1]).ravel()
threshold_rows.append({
"threshold": threshold,
"botnet_recall": recall_score(y_valid, predictions, zero_division=0),
"botnet_precision": precision_score(y_valid, predictions, zero_division=0),
"botnet_f1": f1_score(y_valid, predictions, zero_division=0),
"false_positive_rate": fp / (fp + tn) if fp + tn else 0.0,
"alerts": int(predictions.sum()),
})
threshold_table = pd.DataFrame(threshold_rows)
acceptable = threshold_table.loc[threshold_table["false_positive_rate"] <= 0.05]
selection_pool = acceptable if not acceptable.empty else threshold_table
selected_row = selection_pool.sort_values(
["botnet_recall", "botnet_f1", "false_positive_rate"],
ascending=[False, False, True],
).iloc[0]
operating_threshold = float(selected_row["threshold"])
display(selected_row.to_frame("selected_value"))In [ ]:
test_scores = detector.predict_proba(X_test)[:, 1]
test_predictions = (test_scores >= operating_threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, test_predictions, labels=[0, 1]).ravel()
metrics_table = pd.DataFrame([{
"threshold": operating_threshold,
"accuracy": accuracy_score(y_test, test_predictions),
"botnet_precision": precision_score(y_test, test_predictions, zero_division=0),
"botnet_recall": recall_score(y_test, test_predictions, zero_division=0),
"botnet_f1": f1_score(y_test, test_predictions, zero_division=0),
"pr_auc": average_precision_score(y_test, test_scores),
"false_positive_rate": fp / (fp + tn) if fp + tn else 0.0,
"false_positives": int(fp),
"false_negatives": int(fn),
"alerts_per_10000": 10000 * int(test_predictions.sum()) / len(y_test),
}])
display(metrics_table.round(4))
fig, ax = plt.subplots(figsize=(6, 5))
ConfusionMatrixDisplay.from_predictions(
y_test, test_predictions, labels=[0, 1],
display_labels=["Normal", "Botnet"], cmap="Blues", colorbar=False, ax=ax,
)
ax.set_title("CTU-13 Botnet detector — held-out labeled flows")
fig.tight_layout()
fig.savefig(OUTPUT_DIR / "ctu13_confusion_matrix.png", dpi=160)
plt.show()In [ ]:
all_scores = detector.predict_proba(flows[feature_columns])[:, 1]
scored_flows = flows.copy()
scored_flows["botnet_score"] = all_scores
scored_flows["botnet_alert"] = (all_scores >= operating_threshold).astype(int)
source_triage = (
scored_flows.groupby("src_addr", dropna=False)
.agg(
flow_count=("src_addr", "size"),
mean_botnet_score=("botnet_score", "mean"),
max_botnet_score=("botnet_score", "max"),
botnet_alerts=("botnet_alert", "sum"),
observed_botnet_labels=("binary_label", lambda values: int((values == 1).sum())),
uncertain_background_flows=("binary_label", lambda values: int((values == -1).sum())),
)
.reset_index()
)
source_triage["alert_rate"] = (
source_triage["botnet_alerts"] / source_triage["flow_count"]
)
source_triage = source_triage.sort_values(
["botnet_alerts", "max_botnet_score", "flow_count"], ascending=False
)
evidence_columns = [
"start_time", "src_addr", "dst_addr", "protocol", "src_port", "dst_port",
"duration", "total_packets", "total_bytes", "label_family", "raw_label",
"botnet_score", "botnet_alert",
]
top_flows = scored_flows.loc[scored_flows["botnet_alert"].eq(1), evidence_columns].sort_values(
"botnet_score", ascending=False
).head(500)
display(source_triage.head(10))
display(top_flows.head(10))
fig, ax = plt.subplots(figsize=(9, 5))
for family, color in [("Normal", "tab:green"), ("Botnet", "tab:red"), ("Background", "tab:gray")]:
values = scored_flows.loc[scored_flows["label_family"].eq(family), "botnet_score"]
if not values.empty:
ax.hist(values, bins=50, alpha=0.45, density=True, label=family, color=color)
ax.axvline(operating_threshold, color="black", linestyle="--", label="Operating threshold")
ax.set_title("CTU-13 botnet-score distribution")
ax.set_xlabel("Predicted Botnet probability")
ax.set_ylabel("Density")
ax.legend()
fig.tight_layout()
fig.savefig(OUTPUT_DIR / "ctu13_score_distribution.png", dpi=160)
plt.show()In [ ]:
metrics_table.to_csv(OUTPUT_DIR / "ctu13_botnet_metrics.csv", index=False)
source_triage.head(500).to_csv(OUTPUT_DIR / "ctu13_source_triage.csv", index=False)
top_flows.to_csv(OUTPUT_DIR / "ctu13_top_flows.csv", index=False)
result = metrics_table.iloc[0]
background_alerts = int(
scored_flows.loc[scored_flows["label_family"].eq("Background"), "botnet_alert"].sum()
)
analyst_summary = "\n".join([
"# CTU-13 Botnet Flow Detection — Analyst Summary", "",
"## Labeled-flow evaluation", "",
f"- Operating threshold selected on validation data: {operating_threshold:.2f}",
f"- Botnet precision: {result['botnet_precision']:.3f}",
f"- Botnet recall: {result['botnet_recall']:.3f}",
f"- Botnet F1: {result['botnet_f1']:.3f}",
f"- PR-AUC: {result['pr_auc']:.3f}",
f"- False-positive rate: {result['false_positive_rate']:.3f}", "",
"## Triage", "",
f"- Background flows above threshold: {background_alerts}",
f"- Highest-priority source: {source_triage.iloc[0]['src_addr']}",
"- Background is uncertain traffic; an alert is an investigation lead, not verified ground truth.", "",
"## Limitations", "",
"This experiment uses a bounded sample from one CTU-13 scenario and a random stratified split.",
"Its result may be optimistic for the same botnet family and network environment.",
"A stronger follow-up trains on one or more scenarios and tests on a different scenario.",
"IP addresses were excluded from model inputs and used only for analyst aggregation.",
])
(OUTPUT_DIR / "ctu13_analyst_summary.md").write_text(analyst_summary, encoding="utf-8")
print(analyst_summary)In [ ]:
expected_files = {
"ctu13_botnet_metrics.csv", "ctu13_source_triage.csv", "ctu13_top_flows.csv",
"ctu13_confusion_matrix.png", "ctu13_score_distribution.png",
"ctu13_analyst_summary.md",
}
assert set(flows["label_family"]).issubset({"Botnet", "Normal", "Background"})
assert not {"src_addr", "dst_addr", "raw_label", "label_family", "binary_label"}.intersection(feature_columns)
assert set(y_test.unique()) == {0, 1}
assert 0.0 <= operating_threshold <= 1.0
assert len(test_scores) == len(y_test)
assert set(source_triage.columns) >= {"src_addr", "flow_count", "botnet_alerts", "alert_rate"}
assert expected_files.issubset({path.name for path in OUTPUT_DIR.iterdir()})
print("✓ Lab 7 completed")