Python primer
This primer covers the parts of Python and its scientific libraries that the labs use: core Python, reading errors, virtual environments, NumPy, pandas, Matplotlib and scikit-learn, and the use of random seeds. It assumes you have written some Python before. The examples use the course dataset, data/flows.csv, which Lesson 1.3 describes, so by the end you will have loaded it, explored it and trained a first detector on it.
Run the code yourself as you read, in a Colab notebook or in Jupyter on your own computer (see How to use this course). The blocks build on each other, so run them in order. The output of each line is shown as a comment after it, or in a comment block below the code. These outputs were produced with the package versions in requirements.txt; with other versions the last digits of some numbers may differ.
Core Python
Lists hold ordered sequences and are indexed from 0. Negative indices count from the end, and a slice a[i:j] takes the items from position i up to, but not including, position j. Dictionaries map keys to values.
ports = [22, 80, 443, 3389]
print(ports[0]) # 22
print(ports[-1]) # 3389
print(len(ports)) # 4
print(ports[1:3]) # [80, 443]
service = {"ssh": 22, "http": 80, "https": 443}
print(service["https"]) # 443
service["rdp"] = 3389
print(sorted(service)) # ['http', 'https', 'rdp', 'ssh']
A loop and a list comprehension can build the same list. The comprehension can also filter.
squares = []
for p in ports:
squares.append(p * p)
print(squares) # [484, 6400, 196249, 11485321]
print([p * p for p in ports]) # [484, 6400, 196249, 11485321]
print([p for p in ports if p > 100]) # [443, 3389]
Functions take positional and keyword arguments, and keyword arguments can have defaults. An f-string formats values inside text, and a format specification after a colon controls how a number is shown, for example :, for thousands separators or :.2% for a percentage with two decimals.
def is_large(byte_count, threshold=1_000_000):
"""Return True if a transfer is larger than the threshold."""
return byte_count > threshold
n = 2_500_000
print(f"{n:,} bytes large? {is_large(n)}")
# 2,500,000 bytes large? True
print(f"{n:,} bytes large at 5 MB? {is_large(n, threshold=5_000_000)}")
# 2,500,000 bytes large at 5 MB? False
Libraries are imported once at the top of a notebook, usually under short conventional names: import numpy as np, import pandas as pd and import matplotlib.pyplot as plt.
Reading errors
When something goes wrong, Python stops and prints a traceback. Read it from the bottom up. The last line names the type of error and gives a message, and the lines above it show the chain of calls that led there, ending with the line where the error was raised, which in the example below is a line of your own code. Asking a dictionary for a key it does not contain produces this traceback (file paths shortened).
Traceback (most recent call last):
File "ports.py", line 2, in <module>
print(service["telnet"])
~~~~~~~^^^^^^^^^^
KeyError: 'telnet'
The last line says that the key 'telnet' is missing, and the line above points at the expression that asked for it. Misspelling a column name in pandas gives the same kind of error, KeyError: 'src_byte', after a longer traceback that passes through the internals of pandas. In that case skip the frames inside the library and look for the last frame that shows your own code. Three other errors are common when working in notebooks. A NameError means a variable was used before it was defined, often because a notebook cell above it was not run. A ModuleNotFoundError means a package is not installed in the environment the notebook is using. A ValueError or TypeError usually means a function received data of the wrong shape or type, and printing the .shape or type() of its inputs usually shows which.
Virtual environments and pinned versions
A virtual environment is a folder that holds its own Python interpreter and its own installed packages, so that the packages of one project do not interfere with those of another. On your own computer, create one for this course in the top folder of the repository with python -m venv .venv and activate it with .venv\Scripts\activate (on macOS and Linux, create it with python3 -m venv .venv and activate it with source .venv/bin/activate). Once it is active, install the packages with python -m pip install -r requirements.txt. The file requirements.txt pins the exact version of each package, such as numpy==2.5.2. Pinning matters because a new release of a library can change a default or a numerical routine and with it the printed results. To see what is installed, run python -m pip list, and to check one package from Python, print its __version__ attribute, for example np.__version__.
NumPy
NumPy's array holds many numbers of one type and applies arithmetic to all of them at once, without a Python loop. This is called vectorization, and it is both shorter to write and much faster than a loop.
import numpy as np
sizes = np.array([120, 540, 80, 1500, 240])
print(sizes.shape) # (5,)
print(sizes.dtype) # int64
print(sizes * 2) # [ 240 1080 160 3000 480]
print(sizes.mean()) # 496.0
print(round(sizes.std(), 1)) # 527.2
A two-dimensional array is a table with rows and columns, which is how a dataset of several features is stored. The index X[i] selects row i, X[:, j] selects column j, and the argument axis=0 makes an aggregate run down the rows, giving one value per column.
X = np.array([[1, 120],
[5, 540],
[0, 80]])
print(X.shape) # (3, 2)
print(X[0]) # [ 1 120]
print(X[:, 1]) # [120 540 80]
print(X.mean(axis=0)) # [ 2. 246.66666667]
A comparison on an array gives a boolean mask, an array of True and False values. Indexing with the mask keeps the elements where it is True. Because True counts as 1 and False as 0, the sum of a mask counts the matches and its mean gives their share.
big = sizes > 500
print(big) # [False True False True False]
print(sizes[big]) # [ 540 1500]
print(big.sum()) # 2
print(big.mean()) # 0.4
Random numbers in the course come from a generator object created with np.random.default_rng(seed). The seed fixes the sequence, so the same seed gives the same numbers on every run, while successive calls on one generator continue the sequence.
rng = np.random.default_rng(42)
print(rng.integers(0, 100, size=5)) # [ 8 77 65 43 43]
print(rng.integers(0, 100, size=5)) # [85 8 69 20 9]
rng = np.random.default_rng(42)
print(rng.integers(0, 100, size=5)) # [ 8 77 65 43 43]
rng = np.random.default_rng(0)
# 10,000 draws, each True with probability 0.03
coin = rng.random(10_000) < 0.03
print(coin.mean()) # 0.0297
Older code calls np.random.seed(42) and then functions such as np.random.rand, which use a hidden global generator. Do not mix the two styles in one program, since it becomes unclear which stream a result came from. The labs use generator objects only, and pass the generator, or a seed, to every function that needs randomness.
pandas
pandas stores tables as a DataFrame, whose columns are named and may have different types. Each column is a Series. The labs load the course dataset from a local copy if it exists and from GitHub otherwise, so the same code runs in Colab and on your own computer.
import os
import pandas as pd
LOCAL = "../../data/flows.csv"
URL = ("https://raw.githubusercontent.com/aalbata/"
"machine-learning-for-cybersecurity/main/data/flows.csv")
flows = pd.read_csv(LOCAL if os.path.exists(LOCAL) else URL)
print(flows.shape) # (37873, 21)
print(flows.columns.tolist()) # output wrapped here to fit the page
# ['timestamp', 'day', 'hour', 'site', 'src_host', 'protocol',
# 'service', 'duration', 'src_bytes', 'dst_bytes', 'packets',
# 'failed_logins', 'count', 'srv_count', 'serror_rate',
# 'rerror_rate', 'same_srv_rate', 'dst_host_count',
# 'ticket_opened', 'attack_type', 'label']
The first things to do with a new table are to look at a few rows, the column types and summary statistics. Double brackets select several columns as a smaller DataFrame. In pandas 3, text columns have the type str.
print(flows[["timestamp", "site", "service", "src_bytes",
"attack_type", "label"]].head(3))
# timestamp site service src_bytes attack_type label
# 0 2025-03-03T00:03:05Z B https 8539765 normal 0
# 1 2025-03-03T00:04:24Z A dns 65 normal 0
# 2 2025-03-03T00:05:51Z A dns 37 normal 0
print(flows.dtypes.value_counts())
# int64 11
# str 6
# float64 4
# Name: count, dtype: int64
print(flows[["duration", "src_bytes", "failed_logins"]]
.describe().round(1))
# duration src_bytes failed_logins
# count 37873.0 37873.0 37873.0
# mean 19.4 211119.4 0.0
# std 113.4 2839253.2 0.3
# min 0.0 2.0 0.0
# 25% 0.3 106.0 0.0
# 50% 1.1 447.0 0.0
# 75% 2.9 1210.0 0.0
# max 7926.4 201223301.0 6.0
The summary already says something about security data. The mean of src_bytes is far above its median and its maximum is enormous, because a few connections, such as backups, transfer vastly more than the rest. Such skewed features are usually transformed with a logarithm before modeling.
The next question for any detection dataset is how rare the attacks are. The method value_counts counts each distinct value, and the mean of a 0/1 column is the share of ones.
print(flows["label"].value_counts())
# label
# 0 36807
# 1 1066
# Name: count, dtype: int64
print(f"{flows['label'].mean():.2%} of records are attacks")
# 2.81% of records are attacks
print(flows["attack_type"].value_counts())
# attack_type
# normal 36807
# scan 338
# bruteforce 266
# c2beacon 185
# dos 165
# exfil 112
# Name: count, dtype: int64
Rows are filtered with a boolean mask, just as in NumPy. Conditions are combined with & (and), | (or) and ~ (not), and each condition must be wrapped in parentheses.
ssh = flows[flows["service"] == "ssh"]
print(len(ssh)) # 1728
night_uploads = flows[(flows["src_bytes"] > 100_000)
& (flows["hour"] < 5)]
print(len(night_uploads)) # 430
print(night_uploads["attack_type"].value_counts())
# attack_type
# normal 397
# exfil 33
# Name: count, dtype: int64
Large uploads at night are mostly normal records, the nightly backups described in Lesson 1.3, with some exfiltration among them. A rule that alerted on every large night-time upload would bury the analysts in backups.
The parentheses are needed because & binds more tightly than > and < in Python. Without them, flows["src_bytes"] > 100_000 & flows["hour"] < 5 is read as a chained comparison around 100_000 & flows["hour"], and pandas stops with ValueError: The truth value of a Series is ambiguous. The message is confusing the first time you see it, and missing parentheses are a frequent cause.
The indexer .loc selects by row label and column name at the same time, and it also accepts a boolean mask in place of row labels. After read_csv, the row labels are the integers 0, 1, 2 and so on. With .loc, a slice of labels includes its end point, unlike a Python slice.
print(flows.loc[0, "service"]) # https
print(flows.loc[:2, ["site", "service", "label"]])
# site service label
# 0 B https 0
# 1 A dns 0
# 2 A dns 0
mask = flows["failed_logins"] >= 5
cols = ["src_host", "service", "failed_logins", "attack_type"]
print(flows.loc[mask, cols].head(4))
# src_host service failed_logins attack_type
# 2525 B036 ssh 5 bruteforce
# 4496 A026 ssh 6 bruteforce
# 4919 A089 ssh 5 bruteforce
# 5114 B014 ssh 6 bruteforce
Use .loc whenever you assign values to a subset of rows, as in flows.loc[mask, "column"] = value. Assigning through two separate indexing steps, such as flows[mask]["column"] = value, modifies a temporary copy and leaves flows unchanged; pandas 3 warns with a ChainedAssignmentError when this happens.
The method groupby splits the rows by the values of a column, applies an aggregate to each group, and combines the results.
print(flows.groupby("site")["label"]
.agg(["size", "sum", "mean"]).round(4))
# size sum mean
# site
# A 25512 504 0.0198
# B 12361 562 0.0455
print(flows.groupby("attack_type")["src_bytes"].median())
# attack_type
# bruteforce 776.5
# c2beacon 419.0
# dos 29.0
# exfil 3415089.5
# normal 454.0
# scan 59.5
# Name: src_bytes, dtype: float64
The share of attacks, which Lesson 1.2 calls the base rate, is more than twice as high at site B as at site A, a difference Unit 11 returns to. New columns are computed from existing ones, which is how features are engineered. Adding 1 before taking a logarithm or dividing avoids the infinite values that zeros would otherwise produce.
flows["log_src_bytes"] = np.log10(flows["src_bytes"] + 1)
flows["bytes_ratio"] = flows["src_bytes"] / (flows["dst_bytes"] + 1)
cols = ["src_bytes", "dst_bytes", "log_src_bytes", "bytes_ratio"]
print(flows[cols].head(3).round(3))
# src_bytes dst_bytes log_src_bytes bytes_ratio
# 0 8539765 682 6.931 12503.316
# 1 65 165 1.820 0.392
# 2 37 428 1.580 0.086
Finally, check for missing values and duplicates. The course dataset has no missing values, but it does contain records that were logged twice.
print(flows.isna().sum().sum()) # 0
print(flows.duplicated().sum()) # 1103
Real data usually has gaps. The method isna marks them, and fillna replaces them, here with the median of the column. Whether to fill, drop or flag missing values is a modeling decision that Unit 2 discusses.
small = pd.DataFrame({"host": ["A001", "A002", "A003"],
"duration": [1.5, None, 0.2]})
print(small.isna().sum())
# host 0
# duration 1
# dtype: int64
print(small["duration"].fillna(small["duration"].median()))
# 0 1.50
# 1 0.85
# 2 0.20
# Name: duration, dtype: float64
Matplotlib
Matplotlib draws figures through a figure object and one or more axes. The following code compares the distribution of the logarithm of bytes sent for normal and attack records. Because there are far more normal records, density=True scales each histogram to unit area so that the shapes can be compared.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6, 3))
for label, name in [(0, "normal"), (1, "attack")]:
values = flows.loc[flows["label"] == label, "log_src_bytes"]
ax.hist(values, bins=40, density=True, histtype="step", label=name)
ax.set_xlabel("log10(src_bytes + 1)")
ax.set_ylabel("density within class")
ax.legend()
plt.show()
The two distributions overlap over most of their range, which is why a detector cannot rely on this feature alone. Lesson 1.3 shows the same comparison as a figure. A bar chart shows counts per category, here the number of records of each attack family.
counts = flows.loc[flows["label"] == 1, "attack_type"].value_counts()
fig, ax = plt.subplots(figsize=(6, 3))
ax.bar(counts.index, counts.values)
ax.set_xlabel("attack family")
ax.set_ylabel("records")
plt.show()
Label every axis with the quantity and its unit, and avoid titles that repeat what the axes already say.
scikit-learn
Every scikit-learn classifier follows the same pattern. You create it with its settings, call fit on training data, and call predict or predict_proba on new data. The features are a table X with one row per example, and the labels are a vector y.
A detector learns from the past and is used on the future, so the course splits the data by time. Here the first ten days are used for training and the last four for testing. The features are eleven numeric measurements of each connection, all known when the connection ends. They leave out ticket_opened, which an analyst fills in later, the identifiers and times (src_host, timestamp, day and hour), the categorical columns, and of course label and attack_type.
features = ["duration", "src_bytes", "dst_bytes", "packets",
"failed_logins", "count", "srv_count", "serror_rate",
"rerror_rate", "same_srv_rate", "dst_host_count"]
train = flows[flows["day"] <= 9]
test = flows[flows["day"] >= 10]
X_train, y_train = train[features], train["label"]
X_test, y_test = test[features], test["label"]
print(X_train.shape, X_test.shape) # (27039, 11) (10834, 11)
print(f"{y_train.mean():.4f} {y_test.mean():.4f}") # 0.0231 0.0408
A pipeline chains preprocessing steps and a model into one object. Here it takes the logarithm of one plus each feature (np.log1p), standardizes the result to mean 0 and variance 1, and fits a logistic regression. Because the scaling is part of the pipeline, its means and variances are learned from the training data only.
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import FunctionTransformer, StandardScaler
model = make_pipeline(FunctionTransformer(np.log1p), StandardScaler(),
LogisticRegression(max_iter=1000))
model.fit(X_train, y_train)
pred = model.predict(X_test) # 0 or 1 for each test record
# estimated probability of an attack for each test record
score = model.predict_proba(X_test)[:, 1]
print(pred[:10]) # [0 0 0 0 0 0 0 0 0 0]
print(score[:3].round(4)) # [0.0026 0.0033 0.053 ]
The label in this dataset depends on the features, because the generator gives each kind of behavior its own distributions, so the model has something real to learn. Accuracy would say little here (Lesson 1.2), so the evaluation uses the confusion matrix, recall, precision and the false positive rate.
from sklearn.metrics import (confusion_matrix, precision_score,
recall_score)
tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
print(f"TP={tp} FN={fn} FP={fp} TN={tn}")
# TP=146 FN=296 FP=13 TN=10379
print(f"recall {recall_score(y_test, pred):.3f}")
# recall 0.330
print(f"precision {precision_score(y_test, pred):.3f}")
# precision 0.918
print(f"FPR {fp / (fp + tn):.4f}")
# FPR 0.0013
The method predict applies a threshold of 0.5 to the score. A lower threshold catches more attacks and raises more false alerts.
pred_low = (score >= 0.1).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, pred_low).ravel()
print(f"TP={tp} FN={fn} FP={fp} TN={tn}")
# TP=205 FN=237 FP=148 TN=10244
print(f"recall {tp / (tp + fn):.3f}, precision {tp / (tp + fp):.3f}")
# recall 0.464, precision 0.581
Choosing the threshold from the costs of errors or from an alert budget is the subject of Lessons 1.2 and 2.3. This model differs slightly from the one in Lab 1, which also uses the categorical columns protocol and service and the hour of the day, so its numbers differ too.
Reproducibility with seeds
Many models and utilities use randomness, and each accepts a random_state argument that fixes it. With the same seed, the same data and the same package versions, a random forest is identical from run to run. A different seed gives a slightly different model, which may or may not show in a given measure, so the loop below prints both recall and precision.
from sklearn.ensemble import RandomForestClassifier
for seed in [0, 0, 1]:
forest = RandomForestClassifier(n_estimators=50,
random_state=seed, n_jobs=1)
forest.fit(X_train, y_train)
pred_rf = forest.predict(X_test)
rec = round(recall_score(y_test, pred_rf), 4)
prec = round(precision_score(y_test, pred_rf), 4)
print(seed, rec, prec)
# 0 0.4774 0.9769
# 0 0.4774 0.9769
# 1 0.4774 0.9814
Seeds 0 and 1 happen to give the same recall, but the two forests flag different test records and their precision differs. The variation across seeds is a reminder that a single run is one sample. When two models differ by less than the variation across seeds, the difference should not be reported as an improvement.
The function train_test_split makes random splits and also takes random_state. Its argument stratify keeps the share of each class nearly equal in both parts, as close as whole numbers of records allow.
from sklearn.model_selection import train_test_split
X_a, X_b, y_a, y_b = train_test_split(
flows[features], flows["label"], test_size=0.3,
random_state=42, stratify=flows["label"])
print(X_a.shape, X_b.shape) # (26511, 11) (11362, 11)
print(f"{y_a.mean():.4f} {y_b.mean():.4f}") # 0.0281 0.0282
A random split like this is reproducible, but it is the wrong split for this dataset. It mixes future days into training, so the test overstates how the detector would perform, and it places copies of duplicated records on both sides of the split. Use it only when the examples really are independent of time, and prefer a split by time for detection data (Lesson 1.3 and Unit 2). The seed fixes which records fall on each side of the split, but it does not remove either problem. In PyTorch, used from Unit 5 on, the equivalent call is torch.manual_seed(seed).
Quick reference
The table below collects the operations of this primer, in the order they appear, with the code that performs each. In it, df stands for any DataFrame and g for a grouped DataFrame.
| Task | Code |
|---|---|
| Seeded random numbers | rng = np.random.default_rng(seed) |
| Load a CSV file | pd.read_csv(path) |
| Look at a table | df.head(), df.dtypes, df.describe() |
| Count values | df["label"].value_counts() |
| Share of a class | df["label"].mean() |
| Filter rows | df[(df["a"] > 1) & (df["b"] == "x")] |
| Select by label | df.loc[mask, ["a", "b"]] |
| Group rows | g = df.groupby("site") |
| Aggregate groups | g["label"].mean() |
| Make a feature | df["log_a"] = np.log10(df["a"] + 1) |
| Missing values | df.isna().sum() |
| Duplicates | df.duplicated().sum() |
| Plot a distribution | ax.hist(values, bins=40, density=True) |
| Split by time | train = df[df["day"] <= 9] |
| Train a model | model.fit(X_train, y_train) |
| Score new data | model.predict_proba(X_test)[:, 1] |
| Evaluate a detector | confusion_matrix, recall_score, precision_score |
| Fix randomness | random_state=seed |