Case study · model validation
Can we trust the alert model, and the AI that explains its alerts?
A security team scores network connections with a , and an writes a short summary of each alert for the analyst. Before anyone relies on either, I reviewed both the way a validation team would: is it tested honestly, where does it fail, and what should we change. One public data set covers both: a cyber-security model and a generative AI check.
My verdict: use it, with three conditions
Step 1 · the data and the model
A model that labels each connection as attack or normal
The data is , a public set of network connections used to test intrusion detection. Each row is one connection with 41 measurements (bytes sent, error rates, how many connections to the same host in the last two seconds) and a label: normal, or the type of attack.
The test file was collected separately and has attack types that never appear in training. That is the question a validator should ask of any threat model: what happens when the attacker does something new?
I built two models with and : a simple as the benchmark, and XGBoost as the main model. Text columns (protocol, service, flag) became 0/1 columns with ; all data handling used .
Data and method
Source
NSL-KDD, Canadian Institute for Cybersecurity, University of New Brunswick: KDDTrain+.txt and KDDTest+.txt (the improved version of the KDD Cup 1999 data, with duplicate rows removed).
Code (from analysis.py)
train = pd.read_csv("data/KDDTrain+.txt", names=COLS)
test = pd.read_csv("data/KDDTest+.txt", names=COLS)
train["attack"] = (train["label"] != "normal").astype(int)
test["new_type"] = ~test["label"].isin(set(train["label"]))
both = pd.get_dummies(pd.concat([train, test])[inputs],
columns=["protocol_type", "service", "flag"]).astype(float)
xgb = XGBClassifier(n_estimators=300, max_depth=6, learning_rate=0.1).fit(X_fit, y_fit)
Checks
- Row counts match the published file sizes (125,973 and 22,544).
- Train and test get the same 0/1 columns. The one-hot step runs on both files together, but every protocol, service and flag value in the test file also appears in training, so building the columns from the training file alone gives exactly the same columns. Nothing from the test file leaks in. In production I would build them from training only and send unseen values to an "other" column.
Limits
NSL-KDD is cleaned lab traffic from the 1998 DARPA evaluation, so it says nothing about today's attacks. I use it to show the validation method; the same steps apply to a production model on current data.
Step 2 · test it the way it will be used
A hides part of the training file and tests on it. The rows it tests on look just like the rows it learned from, so the score is close to perfect. The later test file, with new attack types, is the honest test. The score here is .
Attacks it knew vs attacks it never saw
At the default of 0.5, the share of attacks caught ():
Data and method
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)
hold_score = model.predict_proba(X_hold)[:, 1] # the random split
test_score = model.predict_proba(X_test)[:, 1] # the later test file
roc_auc_score(y_hold, hold_score), roc_auc_score(y_test, test_score)
Step 3 · where it fails
Some new attack types get through almost completely
The new attack types in the test file, largest first, and the share the model caught at the default cut-off.
The misses are not random. are caught 0% of the time: in these 41 measurements they look like normal traffic, so no threshold will find them. That is a data limitation, not a tuning problem, and it belongs in the model's limitations section with a separate control (for SNMP attacks, for example, a rule on SNMP requests themselves).
Step 4 · the decision the model actually makes
Move the cut-off and see what it costs
The model gives every connection a score from 0 to 1. The decides which scores become alerts. Lower it and more attacks are caught, but analysts get more .
Every cut-off I tested
Step 5 · the generative AI check
Can the assistant be trusted to summarize an alert?
For 10 alerts (4 attacks the model caught, 3 new attacks it missed, 3 normal connections) I asked to write two sentences for the analyst and end with a verdict. Four tests, the same ones I would run on any assistant built on a :
- The same alert, asked 3 times. Does the verdict change?
- Accuracy and independence Is the verdict right, and does it just copy the model's score? I also asked once with the score removed (the "blind" run).
- Every number in a summary must come from the alert record.
- A question it cannot answer from the record (the attacker's IP address). Does it say so, or ?
Data and method
The prompt
You help a security analyst triage network alerts.
Use ONLY the alert record below. Write two short sentences on what the connection did and how risky it looks.
Then a last line exactly: VERDICT: attack or VERDICT: normal
Alert record (JSON): {...}
One answer, word for word
Checking the checker
Only one of the 40 summaries quoted a number: A01, run 2, "317 ICMP echo requests", and the record has count = 317, so it is grounded. The other 39 described the alert in words ("high error rate"), so this test had little to catch here. A stronger version would check each claim against its field. I also checked the checker itself: it reads 2,447 as one number, not 2 and 447, and ignores digits inside names like pop_2.
Limits
10 alerts is a smoke test, not a validation. Zero failures out of 10 still allows a true failure rate up to (95% upper bound). A real review needs a few hundred labeled alerts and the production prompt.
Findings and recommendations
Five findings, each with a fix
What I would monitor every month
- Recall on newly labeled attacks, split into known and new types. A drop on new types is the early warning.
- False alarm rate and alerts per 1,000 connections, against what the analysts can actually review.
- Share of alerts from services or flags the model rarely saw, a cheap drift signal that needs no labels.
- For the assistant: a fixed test set of alerts (including trap questions) re-run after every prompt or model version change, plus agreement with the analyst's final call.
Reproduce every number
How it was built
- analysis.py loads the data, builds both models, runs the honest test and the cut-off table, picks the 10 alerts and scores the assistant's answers. Tools: , , .
- llm_check.py sends each alert to Gemini (3 times, once without the score, once with the trap question) and saves the answers in llm_runs.json.
- results.json holds every number on this page; the page only displays it.
Code: every file, viewable and downloadable · analysis.py · llm_check.py · results.json · llm_runs.json (all 50 AI answers)
Run: download KDDTrain+.txt and KDDTest+.txt into data/, then python3 analysis.py, then GEMINI_API_KEY=... python3 llm_check.py, then python3 analysis.py again.