AI for Cyber Defenders · lesson 5 of 8 · 120 min
An employee logs in from Riverbend in the evening. A few hours later, at 3am, the same account logs in from a city 2,700 km away on a brand-new device. Nobody labeled that as an attack. Can a model still notice it is weird?
Learning “normal” without labels
In Lab 3 we had labels. In real SOCs, most data has none: nobody marks each login as good or bad. Unsupervised learning finds structure without labels. Anomaly detection is one kind: learn what normal looks like, then flag what does not fit.
An anomaly is not automatically an attack. A coach traveling for a tournament looks unusual too. Anomaly detection gives you leads to check, not verdicts.
Feature engineering: turning logins into clues
Raw logs say who, when and where. Good features turn that into signals a model can use:
| Feature | How we compute it | Why it matters |
|---|---|---|
| hour / odd_hour | time of login; 1 if before 6am or after 10pm | most staff work days |
| km_from_home | distance from the user’s usual city | logins far from home are rarer |
| speed_kmh | distance from last login ÷ hours between | over ~900 km/h is impossible travel |
| new_device | 1 if the device was never seen before | stolen passwords get used on new devices |
| failed_before | failures just before this login | guessing often precedes success |
Impossible travel is a classic SOC signal: if two logins imply moving faster than a jet, one of them is probably not the real person.
How IsolationForest works
IsolationForest builds many random trees. Each tree keeps splitting the data with random cuts (“hour < 9?”, “km_from_home > 400?”). Weird points are easy to isolate and get cut off in just a few splits. Normal points sit in a crowd and need many splits. Fewer splits = higher anomaly score. The contamination setting tells it roughly what share of data to flag (we use 1%).
The lab code
We generate 30 logins each for 50 employees at fictional cities, then inject 8 suspicious ones. The model never sees the injected column; we only use it to grade. Open a new notebook at colab.research.google.com, paste the code into one cell, and press Shift+Enter. Running locally instead? Save it as a .py file and run python lab5.py.
# Lab 5: Anomaly detection on login behavior with IsolationForest
import numpy as np
import pandas as pd
from sklearn.ensemble import IsolationForest
rng = np.random.default_rng(seed=5)
# Fictional cities with (x, y) positions in km on a simple map
cities = {"Riverbend": (0, 0), "Eastgate": (40, 10), "Lakeside": (-30, 25),
"Capital City": (900, 300), "Far Harbor": (-2500, 1200)}
users = [f"emp{i:02d}" for i in range(1, 51)]
home = {u: rng.choice(["Riverbend", "Eastgate", "Lakeside"]) for u in users}
rows = []
start = pd.Timestamp("2026-04-06")
for u in users:
t = start
for _ in range(30): # 30 logins per user over ~2 weeks
t = t + pd.Timedelta(hours=float(rng.uniform(6, 20)))
hour = int(np.clip(rng.normal(12, 2.5), 6, 20))
t = t.normalize() + pd.Timedelta(hours=hour, minutes=int(rng.integers(0, 60)))
city = home[u] if rng.random() < 0.9 else rng.choice(["Riverbend", "Eastgate", "Lakeside"])
rows.append({"user": u, "time": t, "city": city,
"new_device": int(rng.random() < 0.03),
"failed_before": int(rng.poisson(0.2)), "injected": 0})
# Inject 8 suspicious logins (the "answers" we hope the model finds)
for u, city, hour, newdev, fails in [
("emp03", "Far Harbor", 3, 1, 4), ("emp11", "Capital City", 2, 1, 0),
("emp19", "Far Harbor", 23, 0, 6), ("emp27", "Riverbend", 4, 1, 7),
("emp33", "Capital City", 1, 1, 2), ("emp40", "Far Harbor", 14, 1, 5),
("emp45", "Lakeside", 3, 1, 8), ("emp08", "Capital City", 0, 0, 3)]:
last = max(r["time"] for r in rows if r["user"] == u)
t = last.normalize() + pd.Timedelta(hours=hour) + pd.Timedelta(minutes=30)
if t <= last:
t = last + pd.Timedelta(minutes=45)
rows.append({"user": u, "time": t, "city": city, "new_device": newdev,
"failed_before": fails, "injected": 1})
df = pd.DataFrame(rows).sort_values(["user", "time"]).reset_index(drop=True)
# ---- Feature engineering ----
df["hour"] = df["time"].dt.hour
df["odd_hour"] = ((df["hour"] < 6) | (df["hour"] >= 22)).astype(int)
xy = df["city"].map(cities)
df["x"] = [p[0] for p in xy]
df["y"] = [p[1] for p in xy]
df["prev_x"] = df.groupby("user")["x"].shift().fillna(df["x"])
df["prev_y"] = df.groupby("user")["y"].shift().fillna(df["y"])
df["prev_time"] = df.groupby("user")["time"].shift().fillna(df["time"])
df["km_moved"] = np.hypot(df["x"] - df["prev_x"], df["y"] - df["prev_y"])
hours = (df["time"] - df["prev_time"]).dt.total_seconds() / 3600
df["speed_kmh"] = (df["km_moved"] / hours.clip(lower=0.25)).round(1)
df["impossible_travel"] = (df["speed_kmh"] > 900).astype(int) # faster than a jet
df["home_x"] = df["user"].map(lambda u: cities[home[u]][0])
df["home_y"] = df["user"].map(lambda u: cities[home[u]][1])
df["km_from_home"] = np.hypot(df["x"] - df["home_x"], df["y"] - df["home_y"]).round(0)
features = ["hour", "odd_hour", "km_from_home", "speed_kmh", "new_device", "failed_before"]
print("Logins:", len(df), "| injected suspicious:", df["injected"].sum())
print(df[features].describe().round(1))
# ---- Train IsolationForest (unsupervised: it never sees 'injected') ----
model = IsolationForest(n_estimators=200, contamination=0.01, random_state=0)
model.fit(df[features])
df["anomaly_score"] = (-model.score_samples(df[features])).round(3) # higher = weirder
df["flagged"] = (model.predict(df[features]) == -1).astype(int)
print("\n== Top 12 most unusual logins ==")
cols = ["user", "time", "city", "hour", "km_from_home", "speed_kmh",
"new_device", "failed_before", "anomaly_score", "injected"]
print(df.sort_values("anomaly_score", ascending=False)[cols].head(12).to_string(index=False))
caught = df[(df["flagged"] == 1) & (df["injected"] == 1)]
print(f"\nFlagged {df['flagged'].sum()} logins; caught {len(caught)} of {df['injected'].sum()} injected.")
print("Impossible-travel logins:", df["impossible_travel"].sum())
What you should see
About 1,508 logins. The top of the list is all injected events: Far Harbor and Capital City logins with huge distances and speeds in the thousands of km/h. The model flags 16 logins and catches all 8 injected ones. The other 8 flags are normal employees who did something a little unusual, like a new device in a nearby city. Those are the false positives an analyst would quickly clear.
Context beats math. If HR says an employee is at a conference in Capital City this week, that “anomaly” is closed in 30 seconds.
Try it
- Run the code and note which injected login scored highest and why.
- Remove
speed_kmhfrom the feature list and rerun. How many injected logins are still caught? - Change
contaminationto 0.005 and then 0.03. How do flagged counts and catches change? - Add a legit traveler: give one employee three normal-hours logins in Capital City across two days. Does the model flag them? How would an analyst confirm?
- Pick one flagged login that was not injected and write the two questions you would ask to clear it.
Check yourself
Why is IsolationForest called unsupervised?
It learns from the login features alone, without any labels saying which logins are attacks.
What is impossible travel?
Two logins for one account that would require moving faster than any real trip, suggesting someone else is using the account.
Does a high anomaly score prove an attack?
No. It means the login is unusual. An analyst still checks context like travel plans or a new phone.
Key words
- Unsupervised learning — finding patterns in data without labels
- Anomaly detection — flagging data points that do not fit the normal pattern
- Feature engineering — creating useful input columns from raw data
- Impossible travel — logins too far apart in too little time to be one person
- Contamination — the share of data IsolationForest expects to be anomalous
Previous lesson: Lab 4: Measuring detectors — precision, recall and alert fatigue
Back to AI for Cyber Defenders
Next lesson: Lab 6: Suspicious URL scoring with a random forest
