Skip to content

ML vs Traditional Programming

What you’ll learn

  • the one-line structural difference: rules are written, models are derived
  • a real contest on the same data: 30 hand-tuned rules (0.7167) vs one default model (0.7667)
  • why the 5-point accuracy gap is the least interesting part of that result
  • how each approach responds to more data: one improves, one cannot
  • why a rule gives you one operating point and a model gives you the whole curve
  • when writing the rules is still the correct engineering decision

Intuition

In traditional programming you supply the data and the rules, and the computer produces the answers. In machine learning you supply the data and the answers, and the computer produces the rules.

diagram Diagram mermaid

That inversion is the entire idea, and it sounds almost like a word game until you watch it happen on a problem where the rules are genuinely hard to write.

The contest

Two thousand short messages, half spam and half not. A spam message draws more of its words from a promotional vocabulary — free, winner, prize, click, urgent, offer, cash, limited, guarantee, bonus — and a legitimate message draws more from a work vocabulary. But most words in every message are neutral filler, and roughly a third of the loaded words come from the wrong vocabulary.

Here is one of each:

text
spam: send report work take week your guarantee guarantee that you will meeting also call about ...
ham : have review here you you deadline here notes report want invoice send for would there and ...
text
spam: send report work take week your guarantee guarantee that you will meeting also call about ...
ham : have review here you you deadline here notes report want invoice send for would there and ...

You cannot separate those by eye, which is exactly the point. Real spam looks like real mail.

The traditional approach

Write a rule. Flag a message if it contains at least t words from the keyword list. Two knobs: how many keywords, and how many hits it takes to fire. Try every combination — 10 keyword lengths × 3 thresholds = 30 hand-tuned variants.

RuleAccuracyPrecisionRecall
1 keyword, fire on 1 hit0.55830.64230.2633
5 keywords, fire on 1 hit0.64000.60240.8233
10 keywords, fire on 1 hit0.61170.56410.9833
10 keywords, fire on 3 hits0.71670.74810.6533

Watch the failure mode. Adding keywords drives recall up (0.2633 → 0.9833) and precision down (0.6423 → 0.5641), because a longer list fires on more legitimate mail. Raising the threshold pushes back the other way. You are hand-searching a trade-off curve, and the best point you find is 0.7167.

The learned approach

python
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
 
vec = CountVectorizer()
model = MultinomialNB().fit(vec.fit_transform(X_train), y_train)
python
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
 
vec = CountVectorizer()
model = MultinomialNB().fit(vec.fit_transform(X_train), y_train)

Two lines. No keyword list, no threshold, no tuning, no domain knowledge. Accuracy 0.7667, precision 0.7614, recall 0.7767.

figureThirty hand-tuned rule variants, none of them enoughmatplotlib
Three rising curves for rule thresholds 1, 2 and 3 against the number of keywords, all below a dashed horizontal line at 0.7667 marking the fitted model.Three rising curves for rule thresholds 1, 2 and 3 against the number of keywords, all below a dashed horizontal line at 0.7667 marking the fitted model.
Each curve is one threshold setting; each point is one hand-written rule. The best of all thirty reaches 0.7167. The dashed line is a single MultinomialNB fit with default settings at 0.7667 — above every variant, with no human tuning at all.

Five points is a real but unspectacular win. It is also, by some distance, the least interesting thing that just happened.

Three differences the accuracy number hides

1. The model found the vocabulary itself

The rule needed a human to sit down and think of ten spam words. The model was handed raw text and discovered 55 distinct tokens, weighting each by how much evidence it carries. It never needed a domain expert, and it would have worked identically on a language nobody in the room speaks.

2. Only one of them improves with data

This is the structural difference, and it is not close.

Training examplesLearned modelBest hand-written rule
200.55170.7167
500.58170.7167
1000.64330.7167
2000.70830.7167
5000.75000.7167
1,0000.77500.7167
1,4000.76670.7167

At 20 examples the rule wins by 16 points. The rule is better than the model, and if you stopped there you would ship the rule.

By 500 examples the model has overtaken it, and the rule’s column never moves — it cannot move, because a rule is a constant. Every new labelled message is free improvement for one approach and inert for the other.

figureOnly one of these two lines responds to more datamatplotlib
A rising curve for the learned model crossing a flat dashed line for the hand-written rule at around 500 training examples.A rising curve for the learned model crossing a flat dashed line for the hand-written rule at around 500 training examples.
The rule's accuracy is a horizontal line by construction — it was written once and does not read the training set. The model starts far worse at 20 examples and crosses at roughly 500. Notice the small dip at 1,400: learning curves are noisy, not monotone.

That last detail is worth keeping. The model scores 0.7750 at 1,000 examples and 0.7667 at 1,400. Learning curves wobble; a single dip is not a regression to investigate.

See it move

sketch Rules accumulate; a model absorbs p5.js
New edge cases arrive one at a time. The rule engine grows a new branch for each and its complexity climbs without bound; the model absorbs each one as another training row and its size never changes.

The rule tree is the honest picture of what maintaining a rule engine feels like after two years. The model’s box never changes size — every new edge case is another row, not another branch.

3. A rule is one operating point; a model is a curve

Every rule variant is a single fixed (precision, recall) pair. Want more recall? Rewrite it and re-measure. The model outputs a probability, so moving the threshold sweeps the entire trade-off without refitting anything.

figureThirty rules give thirty points; one model gives the whole curvematplotlib
A scatter of thirty blue points representing rule variants, all sitting below a continuous amber precision-recall curve produced by the fitted model.A scatter of thirty blue points representing rule variants, all sitting below a continuous amber precision-recall curve produced by the fitted model.
Every hand-written rule is one dot. The model's curve passes above all of them, and every point along it is reachable by changing a single number at prediction time. Average precision: 0.8269.

In production this matters more than the headline accuracy. “Legal says we need 95% precision on auto-deletion” is a threshold change with a model, and a rewrite with rules.

What changes in your workflow

Traditional programmingMachine learning
You provideRulesLabelled examples
Computer providesAnswersRules
DebuggingRead the codeInspect data, features, errors
Improving itWrite more logicGet more or better data
CorrectnessProvableStatistical, always
Version controlThe code is the artefactCode and data and weights
Failure modeCrashes, or is visibly wrongQuietly confident and wrong
TestingUnit tests, exact assertionsHeld-out metrics, distributions

The last two rows are where teams get hurt. A rule that breaks throws an exception. A model that breaks returns a plausible number, in the right format, at normal latency, and nothing alerts.

When to write the rules anyway

Machine learning is not a default. Choose it deliberately.

SituationWrite rulesTrain a model
The logic is written down (tax, physics, regulation)
The mapping is complex and unwritable
You have fewer than a hundred examples❌ (see the table above — 0.5517 at n=20)
You need an exact, auditable justification⚠️ Depends on the model
The pattern drifts over time✅ Retrain
Being wrong is catastrophic and unrecoverable
You have plenty of labels and rules keep multiplying

The honest heuristic: if you can write the rule, write the rule. Machine learning earns its complexity when the rules would be numerous, unknown, or moving.

There is also a hybrid answer that is often correct: a small set of hard rules for the cases you know (block this sender, always allow this domain), with a model handling the residue. That is what most real spam filters actually are.

In code

The full contest, reproducible:

rules_vs_learning.py
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import MultinomialNB
 
SPAM_WORDS = ["free", "winner", "prize", "click", "urgent",
              "offer", "cash", "limited", "guarantee", "bonus"]
 
def keyword_rule(docs, keywords, threshold):
    """The traditional program: no learning, no data, just logic."""
    kw = set(keywords)
    return np.array([int(sum(w in kw for w in d.split()) >= threshold)
                     for d in docs])
 
X_train, X_test, y_train, y_test = train_test_split(
    docs, labels, test_size=0.3, random_state=0, stratify=labels)
 
# Search all 30 hand-written variants, by brute force, the way a human would.
best = max(
    (accuracy_score(y_test, keyword_rule(X_test, SPAM_WORDS[:k], t)), k, t)
    for k in range(1, 11) for t in (1, 2, 3)
)
print("best rule:", round(best[0], 4), "with", best[1], "keywords, threshold", best[2])
# best rule: 0.7167 with 10 keywords, threshold 3
 
# One fit. No tuning. No keyword list.
vec = CountVectorizer()
model = MultinomialNB().fit(vec.fit_transform(X_train), y_train)
pred = model.predict(vec.transform(X_test))
print("model    :", round(accuracy_score(y_test, pred), 4))     # 0.7667
print("vocabulary:", len(vec.vocabulary_))                      # 55
rules_vs_learning.py
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import MultinomialNB
 
SPAM_WORDS = ["free", "winner", "prize", "click", "urgent",
              "offer", "cash", "limited", "guarantee", "bonus"]
 
def keyword_rule(docs, keywords, threshold):
    """The traditional program: no learning, no data, just logic."""
    kw = set(keywords)
    return np.array([int(sum(w in kw for w in d.split()) >= threshold)
                     for d in docs])
 
X_train, X_test, y_train, y_test = train_test_split(
    docs, labels, test_size=0.3, random_state=0, stratify=labels)
 
# Search all 30 hand-written variants, by brute force, the way a human would.
best = max(
    (accuracy_score(y_test, keyword_rule(X_test, SPAM_WORDS[:k], t)), k, t)
    for k in range(1, 11) for t in (1, 2, 3)
)
print("best rule:", round(best[0], 4), "with", best[1], "keywords, threshold", best[2])
# best rule: 0.7167 with 10 keywords, threshold 3
 
# One fit. No tuning. No keyword list.
vec = CountVectorizer()
model = MultinomialNB().fit(vec.fit_transform(X_train), y_train)
pred = model.predict(vec.transform(X_test))
print("model    :", round(accuracy_score(y_test, pred), 4))     # 0.7667
print("vocabulary:", len(vec.vocabulary_))                      # 55

And the part that only the model can do:

one_model_many_thresholds.py
proba = model.predict_proba(vec.transform(X_test))[:, 1]
 
for threshold in (0.3, 0.5, 0.7, 0.9):
    flagged = (proba >= threshold).astype(int)
    print(f"threshold {threshold}: "
          f"precision {precision_score(y_test, flagged, zero_division=0):.4f}  "
          f"recall {recall_score(y_test, flagged):.4f}")
one_model_many_thresholds.py
proba = model.predict_proba(vec.transform(X_test))[:, 1]
 
for threshold in (0.3, 0.5, 0.7, 0.9):
    flagged = (proba >= threshold).astype(int)
    print(f"threshold {threshold}: "
          f"precision {precision_score(y_test, flagged, zero_division=0):.4f}  "
          f"recall {recall_score(y_test, flagged):.4f}")

No refitting. The model was trained once; the business decision about precision versus recall happens at prediction time, where it belongs.

Pitfalls

Comparing a tuned rule against an untuned model, or vice versa. The comparison above gave the rules 30 attempts and the model one, with defaults. That is deliberately generous to the rules, and it should be — otherwise the result proves nothing.

Concluding “ML wins” from 0.7667 vs 0.7167. Five points on one synthetic corpus is not a law of nature. The durable findings are the shape of the curves: rules are flat in data, models are not; rules are points, models are curves.

Reaching for ML at n=20. The measured table shows the rule beating the model by 16 points there. Small data is rule territory.

Forgetting the model needs labels. The rule needed zero labelled examples. The model needed 1,400. Somebody had to produce those, and that cost is invisible in every accuracy comparison ever published.

Assuming the model is unmaintainable and the rules are not. In practice a rule engine that has accreted 400 exceptions over six years is far harder to reason about than a retrainable model with a held-out score.

Recap

  • Traditional programming: data + rules → answers. Machine learning: data + answers → rules.
  • On identical data: 30 hand-tuned rules peaked at 0.7167; one default MultinomialNBMultinomialNB fit reached 0.7667.
  • The model discovered a 55-token vocabulary with no domain input.
  • The rule is flat in data by construction. The model went 0.5517 → 0.7750 and overtook the rule at roughly 500 examples — but lost to it at 20.
  • A rule is one (precision, recall) point; a model is the whole curve, selectable at prediction time. Average precision 0.8269.
  • If you can write the rule, write the rule.
quizCheck yourself
  1. At 20 training examples the hand-written rule scored 0.7167 and the model scored 0.5517. What should you ship?

    Show answer

    B — The rule, and revisit once you have a few hundred labelled examples — Ship what works now. The measured crossover was around 500 examples, so at n=20 the rule is genuinely the better engineering choice. The plan is to keep labelling and re-run the comparison — not to prefer ML on principle.

  2. Why can the rule's accuracy column stay at 0.7167 for every training-set size?

    Show answer

    B — A rule is a constant — it never reads the training data, so more of it changes nothing — The keyword list and threshold were written by a human and are fixed. Adding labelled examples cannot alter them. That is the structural difference: data is an input to one approach and irrelevant to the other.

  3. Legal now requires 95% precision on auto-deletion. What does each approach need?

    Show answer

    B — The model needs a threshold change at prediction time; the rule needs to be rewritten and re-measured — predict_proba plus a comparison gives you every operating point on the precision-recall curve from a single fitted model. Each rule variant is one fixed point, so a new precision target means designing a new rule.

  4. The model scored 0.7750 at 1,000 examples and 0.7667 at 1,400. What does the dip mean?

    Show answer

    B — Ordinary noise — learning curves are not monotone, and a single dip is not a regression — Each point is one fit evaluated on one test set, so it carries sampling noise in both. Treat a learning curve as a trend, not a guarantee; investigate a sustained decline, not a single wobble.

  5. Which failure mode is specific to the machine-learning approach?

    Show answer

    B — Returning a confident, well-formatted, wrong answer with nothing alerting — A broken rule usually crashes or is visibly wrong. A broken model returns a plausible value in the right shape at normal latency. This is why monitoring model outputs is a distinct discipline, covered in the deployment phase.

🧪 Try It Yourself

Exercise 1 – Write the traditional program

Exercise 2 – Search all thirty rule variants

Exercise 3 – Learn the rule instead

Exercise 4 – Which one responds to more data?

Exercise 5 – One model, every operating point

Exercise 6 – Find the size at which the model earns its place

Next

The Machine Learning Roadmap — where the work actually goes, counted line by line in a complete end-to-end pipeline.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did