Skip to content
ComputingAmerica

Data

In applied machine learning, the model is the tiebreaker

We built the full pipeline from raw network capture to a live detector, measured where the time went, and the answer was not the modeling.

7 min readComputing America

In short

  • Data cleaning moved validation F1 by more than any hyperparameter search we ran. The cleaning loop is where a model lives or dies; the model itself is a tiebreaker.
  • Choosing the unit of prediction matters more than choosing the algorithm. Labelling individual packets makes most labels wrong, because a malicious session is full of innocuous traffic.
  • A deep model with no sequence structure to exploit loses to gradient-boosted trees on tabular features, no matter how heavily it is regularized.
  • Build the pipeline as decoupled, replayable stages before choosing features. Empty stages are easier to fill than entangled ones, and a crash at stage five should not force a re-run of stage one.
  • Profile before optimizing the part that feels expensive. In our live detector, inference was sub-millisecond and packet parsing was an order of magnitude slower.

This is the supervised counterpart to our fuzzy-hashing research, and it exists to answer a question that work left open: on the same class of problem, with labels available, what does supervised learning actually buy you?

The system is seven decoupled stages. Collect labelled captures, extract packet and flow features, clean and balance, select features by ensemble vote, train four classical models plus a convolutional network, evaluate with real confusion matrices, and deploy a detector that reads a live interface and raises an alert above a probability threshold. Roughly fifty hours of work, accounted for honestly. Most of it was not modeling.

pipeline/main_pipeline.pyPython
def run_pipeline(self):    self.collect_data()        # 1. PCAPs -> data/raw_pcaps/{normal,malicious}    self.extract_features()    # 2. PCAPs -> packet/flow CSVs (scapy)    self.clean_data()          # 3. dedupe, IQR clip, engineer, scale    self.select_features()     # 4. ensemble vote across 4 methods    self.train_models()        # 5. SVM/RF/XGB/MLP + 1D CNN    self.evaluate_models()     # 6. metrics, ROC, confusion matrices    self.deploy_model()        # 7. live scapy sniff -> predict_proba -> alert
One line of the seven is the modelling. Every stage writes its output to disk and reads its input from disk, which is what makes any of them replayable in isolation and is the single most valuable structural decision in the project.

The choice that decided everything

The most consequential decision came at stage two, and it was not a modeling decision. A naive extractor labels every packet and trains on packets. That gives you millions of training samples at the cost of making most labels false, because a malicious session contains plenty of ordinary handshakes and keepalives.

Aggregating packets into flows, keyed on source and destination address, both ports and the protocol, and computing statistics over each flow, is what makes the labels true. Packet counts, byte totals, duration, packet size distribution, inter-arrival time distribution, and derived rates. The model got dramatically easier the moment the unit of prediction matched the unit the label described.

pipeline/pcap_processor.py — flow keyingPython
flow_key = (    packet[IP].src,    packet[IP].dst,    src_port,    dst_port,    protocol,)flows[flow_key].append(packet)
Five fields. That tuple is the whole of the decision described above: it is what changes the unit of prediction from a packet, whose label is usually wrong, to a conversation, whose label is the one the dataset actually asserts.

Two of the features computed over those flows carry most of the separation, and it is worth seeing how much of the result is decided before any model exists. Below, the flows are laid out on those two axes with a threshold rule over them. Move the cuts and the confusion matrix moves with them.

Interactive · Applied machine learning

Two features and a threshold rule

Flow explorer · 146 synthetic flows

drag the sliders

log₁₀ packets / sec →log₁₀ avg packet bytes →01231.522.533.5
rule

confusion matrix

pred 0
pred 1
true 0
90
0
true 1
18
38
precision1.000
recall0.679
f10.809
accuracy0.877
Blue dots are normal flows, red dots are malicious. The dashed red lines are the rule’s decision boundaries; flows in the flagged region get a white outline. The same exercise on real PCAP-derived features is what feature selection and model training automate.
What this isSynthetic flows drawn from the shape of the real capture, not the capture itself. Move the thresholds and the confusion matrix moves with them: the point is how much of the result is decided by the feature extraction and the cut, before any model is chosen.

Where the accuracy actually came from

Switching the outlier treatment from dropping at one and a half times the interquartile range to clipping at three times it moved validation F1 by roughly three and a half points. No grid search we ran produced a delta anywhere near that size. That single comparison is the most useful thing this project taught us, and it is the opposite of where attention usually goes.

pipeline/data_cleaner.py — outlier treatmentPython
numeric_columns = df.select_dtypes(include=[np.number]).columnsfor col in numeric_columns:    if col != 'label':        Q1 = df[col].quantile(0.25)        Q3 = df[col].quantile(0.75)        IQR = Q3 - Q1        lower_bound = Q1 - 3 * IQR        upper_bound = Q3 + 3 * IQR        df[col] = df[col].clip(lower_bound, upper_bound)
The change worth three and a half points of F1 is the word clip. Dropping those rows throws away the attack traffic, because on a network capture the extreme values are not noise: they are the thing you are looking for.

Deduplication was the other surprise. Dropping duplicate flows removed about twenty-two percent of rows. We assumed the counter was broken; it was not. Public capture datasets overlap heavily, because everyone samples from the same well-known sources. A model trained without that step is being evaluated partly on data it memorized.

Feature selection by disagreement

We selected features by ensemble vote across four methods rather than trusting one: an F-test, mutual information, recursive feature elimination and random-forest importance. The value is in the disagreement, not the agreement.

We expected mutual information to be strictly more permissive than the F-test, on the reasoning that a non-linear test should accept everything a linear one accepts and more. It is not, because the two score on different axes, variance against entropy. Engineered binary features sat in mutual information’s sweet spot while looking irrelevant to the F-statistic. A single-method selection would have dropped features that carried real signal, and the ensemble vote exists precisely to cover that gap.

pipeline/feature_selector.py — the votePython
top_f    = set(f_scores['feature'][:20].tolist())top_mi   = set(mi_scores['feature'][:20].tolist())top_rfe  = set(rfe_features)top_tree = set(tree_features) for feature_set in [top_f, top_mi, top_rfe, top_tree]:    for feature in feature_set:        feature_counts[feature] = feature_counts.get(feature, 0) + 1 # Keep anything at least two of the four methods asked for.self.selected_features = [f for f, c in feature_counts.items() if c >= 2]
The threshold on the last line is the only tunable in the stage, and it is a judgement rather than a result. Two votes keeps features a single method would have dropped; four keeps only what everything agrees on, which on this dataset is a much smaller set than it sounds.

Interactive · Feature selection

Feature selection by ensemble vote

Ensemble feature voter · 24 features · 4 methods

mirrors feature_selector.select_best_features()

min votes

19 / 24 selected

packet_rate
4
packets_per_second
4
byte_rate
4
avg_iat
4
avg_packet_size
4
std_iat
3
std_packet_size
3
packet_count
3
duration
3
total_bytes
2
avg_bytes_per_packet
2
min_packet_size
2
max_iat
2
max_packet_size
2
min_iat
2
is_well_known_port
2
is_registered_port
2
dst_port
2
is_dynamic_port
2
src_port
1
syn_ack_ratio
1
protocol
1
dst_ip
0
src_ip
0

features per method (top set)

F-test11
MI15
RFE14
RF imp.15

vote distribution

exactly 0 methods2
exactly 1 method3
exactly 2 methods10
exactly 3 methods4
exactly 4 methods5
Each row is a flow feature. Coloured dots mark the selection methods that placed it in their top-K. The sort is by vote count, then F-score. Move the threshold up and the selected set tightens around the features that multiple methods agree on - exactly what selected = [f for f, c in counts.items() if c >= 2] does in the project.
What this isFour selection methods vote on twenty-four features; you set how many votes a feature needs to survive. The per-method scores are illustrative, calibrated so the ensemble reproduces the kind of rank disagreement the project’s report describes; the vote arithmetic over them is real. Raise the bar and watch the features everyone agrees on stay while the ones a single method liked drop out.

The deep model that did not earn its keep

We trained a one-dimensional convolutional network alongside the classical models, with the full regularization stack: batch normalization, dropout, weight decay and early stopping. It lost to gradient-boosted trees, and not narrowly.

The reason is structural rather than a tuning failure. Convolutions exploit local ordering. Engineered flow statistics have no meaningful ordering between columns, so there is nothing for the architecture to exploit and its inductive bias is simply wrong for the data. If we wanted a deep model to win, the input would have to be raw packet sequences rather than flow scalars, which is a different project with a different data pipeline.

We report this because the incentive in most engagements runs the other way. A convolutional network sounds like more value delivered than a boosted tree. It is not, and a firm that lets a client pay for the more impressive-sounding model when the boring one wins is not doing the client a service.

stage 4/7: feature selection, ensemble votepipeline output
feature               MI   chi2   RFE    L1   votesflow_iat_std           *      *     *     *      4fwd_pkt_len_max        *      *     *     .      3init_win_bytes_fwd     *      .     *     *      3bwd_pkt_len_mean       .      *     .     *      2   dropped--stage 6/7  gradient-boosted trees   F1 0.981stage 6/7  1D convolutional net     F1 0.974
The convolutional network lost, and the largest single gain in the whole pipeline came from clipping outliers in stage 2 rather than from anything in stage 6. We report that because a client who is told the impressive model won learns nothing about where to spend the next dollar.

The number the client actually argues about

A trained classifier does not raise alerts. A threshold on its output does, and choosing that number is where the project stops being a modelling exercise. Every position on the slider trades a missed detection against a false one, and there is no position that is optimal in any sense the mathematics can settle.

It is a business decision wearing a technical costume: what does an investigation cost, what does a miss cost, and how many alerts will the people on the receiving end actually work before they start closing them unread. We present it to clients exactly like this, as a curve with a handle on it, because a threshold chosen by the engineers is a threshold nobody in operations agreed to.

Interactive · Evaluation

Where to put the alert threshold

Alert threshold sweep · 600 simulated predict_proba outputs

mirrors anomaly_detector.alert_threshold

t = 0.700.000.250.500.751.00predict_proba (malicious class)countnormalmalicious
presets

Lower the threshold to catch more attacks (higher recall) at the cost of more false alerts. Raise it for cleaner alerts at the cost of misses. The project ships at 0.7 in config.json, where this synthetic set happens to produce no false positives; around 0.5 both error columns fill and the trade is easier to read.

at this threshold

precision1.000
recall0.665
f10.799
FPR0.000
TP / FN113 / 57
FP / TN0 / 430
alerts / hour~678

assuming 1 flow/sec

Each bar is a probability bin; blue = normal flows the model scored at that probability, red = malicious. The model is honest but not perfect: the two distributions overlap. The job of the threshold is to pick the cut that minimizes the cost of whichever error is worse for your operation.
What this isSix hundred simulated scores, one slider, and the two error columns that a client actually argues about. The distribution is synthetic; the trade-off is not, and neither is the fact that the defensible threshold is a business decision rather than an optimum.

Engineering that made the rest survivable

  • Every stage writes its output to disk and reads its input from disk, so a crash at stage five does not force a re-collection at stage one. Unglamorous, and the single most valuable structural decision in the project.
  • Explicit pipeline state, recording which stages have completed, so any run is replayable from any point.
  • The selected feature column list is written to disk at training time and read back at inference. Never regenerated. Column order is part of the contract between training and serving, and regenerating it is a class of bug that produces confident nonsense rather than an error.
  • One configuration file as the single source of truth for paths, thresholds and hyperparameters, so a run is reproducible by someone who was not there.

Profile the thing, not the story about the thing

We expected model inference to be the bottleneck in the live detector, because inference is the part that feels expensive. It is not close. Scoring a flow is sub-millisecond. The packet parsing and field extraction loop in front of it is at least an order of magnitude slower. Optimizing for throughput means replacing the parser, and touching the model would accomplish nothing.

This generalizes badly for anyone selling AI and well for anyone buying it. The expensive part of a machine learning system is rarely the machine learning. It is acquisition, cleaning, labelling, serving and the plumbing between them, which is exactly the work that gets under-scoped because it is not the part in the demo.

What we would do differently

  • Model the pipeline as a dependency graph rather than a script, so stages can be re-run individually, cached by content hash and parallelized without rewriting the orchestrator.
  • Move oversampling inside the cross-validation loop from day one. Oversampling before the split looks fine until a reviewer asks how leakage was avoided, and the honest answer is that it was not.
  • Version the intermediate datasets, not just the code. "Why did the score drop on Monday" should be a two-minute question.
  • Plot the per-class distribution of every feature before training anything. Half the bugs and most of the wins are visible in those histograms.

The most valuable artifact the project produced was not the trained classifier. It was seven decoupled stages, an explicit column contract and a confusion matrix on every model. Every component inside that skeleton is replaceable. The skeleton is what we carry forward, and it is what we build for clients before we build them a model.

Next step

Tell us what’s breaking.

Forty-five minutes, no charge, no deck. We’ll tell you what we’d do, what it would likely cost, and whether you should be building this at all.