---
title: 'Model Selection'
source: 'https://academia.sh/en/courses/introduction-to-ai-engineering/model-selection'
course: 'Introduction to AI Engineering'
language: en
updated: '2026-10-21T05:02:30+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds three candidates at the simulator level and counts them on the criteria of hit rate, equivalent tokens, steps and requests crossing the organization boundary'
    - 'Shows that the winner changes as the weight sets change and finds the threshold that turns the choice for each criterion'
    - 'Shows with two seeds that the choice itself can depend on the run'
---

# Model Selection

Three candidates are built at the simulator level and counted on four criteria: small capability 0.5000 hit rate / 89.5 equivalent tokens / 7.29 steps / 0 requests crossing, medium capability 0.5833 / 195.3 / 7.00 / 0, large capability 0.7917 / 670.0 / 14.00 / 24. Across five weight sets the winner is medium three times, large once and small once. The weight sweep gives the thresholds: when the hit-rate weight reaches 4 the choice moves from medium to large, when the cost weight reaches 2 it moves from medium to small, and the latency and data-boundary weights do not change the choice up to twelve. When the same computation is repeated with seed 20260219, the winner of the hit-rate weighted set turns from large back to medium.

The previous lesson compared two access modes on three criteria and stopped where the decision is left
to the weights. In a real choice there are not two options. There are several models that could do the
same job; they differ from one another on the axes of capability, token cost, latency and where the data
goes, and none of them is best on all four axes at once.

This lesson builds three candidates **at the simulator level**, counts them on four criteria and answers
a single question: when the weights change, does the winner change, and if it does, at which threshold?
How a weight sweep and the choice of a normalization rule can turn the result was measured in the
Environment Selection lesson of the Data Analysis with Python course; that procedure is not repeated
here, a single normalization rule is used and its result is read.

- **LM65. The three candidates are three settings of a single simulator.** They differ in the width of
  the familiar piece set, the unit multiplier and the length of the response they produce. No model,
  version or provider is named; the candidates are called **small**, **medium** and **large capability**
  and are **fictional**.
- **LM66. The cost unit is the equivalent token.** A token of a candidate is scaled by a fictional
  multiplier that says how many times the account of the smallest candidate's token it holds: small 1,
  medium 4, large 12. No currency is written.
- **LM67. Latency is measured in the number of steps:** the average number of calls per request times
  the number of output tokens produced. Raw time is not written, because generation is sequential and
  the number of steps does not change from run to run.
- **LM68. The small and medium candidates run on their own hardware with open-weight access**, and the
  large candidate is called only through a model endpoint; the number of requests crossing the
  organization boundary is 0, 0 and 24 respectively.
- **LM69. The small candidate has a scaffolding:** for responses that fail the format check, two fictional
  examples are added to the prompt and the request is asked again. This scaffolding raises the hit rate
  and enlarges the number of calls and tokens.
- **LM70. The normalization rule is single:** in each criterion the worst candidate gets 0 and the best
  candidate gets 1, and the values in between are scaled to the range. The weights are applied to these
  normalized numbers.

## Three Candidates and Four Criteria

```python
# SHARED -- FICTIONAL. The three candidates are built on the same simulator; no model or
# provider is named. The candidates differ in familiar piece width, unit multiplier and output
# length. The cost unit is EQUIVALENT TOKENS: number of tokens times the candidate's unit multiplier.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def tokenize(text):
    """Fictional tokenizer: letter pieces are split into fours, digit pieces into twos."""
    out, buffer = [], ""
    for ch in text + " ":
        if ch.isalnum():
            buffer += ch
            continue
        if buffer:
            n = 2 if buffer.isdigit() else 4
            out += [buffer[i:i + n] for i in range(0, len(buffer), n)]
            buffer = ""
        if ch.strip():
            out.append(ch)
    return out


MAIN = [("meter not read estimated invoice issued", "reading"),
        ("invoice amount doubled compared to last month", "payment"),
        ("water pressure dropped during the night", "water"),
        ("planned outage notice did not arrive", "outage"),
        ("subscription transfer was not applied to meter", "subscription")]
EXTRA = ["meter number may have been changed", "address information looks old",
         "same problem did not occur last month", "cannot reach the meter door closed",
         "outage lasted longer than expected", "no automatic payment instruction on meter"]
LEXICON = {"outage": ["outa", "noti"], "reading": ["read", "meas", "esti"],
           "water": ["pres", "drop", "tank", "pipe"],
           "payment": ["invo", "bill", "paym", "amou"],
           "subscription": ["subs", "addr", "tran", "cont"]}
CLASSES = list(LEXICON)
REQUESTS = []
for i in range(24):
    r = rng(SEED + 31 * i)
    a, b = 100000 + int(r() * 380000), 100000 + int(r() * 380000)
    REQUESTS.append({"id": 4100 + i, "label": MAIN[i % 5][1],
                     "text": f"subscriber {4100 + i}: {MAIN[i % 5][0]}, {EXTRA[(5 * i + 2) % 6]}."
                             f" main meter {a} sub meter {b}."})
# (name, piece width, unit multiplier, output tokens, example cost, requests crossing the boundary)
CANDIDATES = [("small", 2, 1, 5, 46, 0), ("medium", 3, 4, 7, 0, 0), ("large", 4, 12, 14, 0, 24)]


def call(t, width, seed):
    """Simulator: picks a label from the pieces it knows; returns ambiguous if the top two tie."""
    tokens, known = tokenize(t["text"]), {s: set(o[:width]) for s, o in LEXICON.items()}
    p = sorted(((sum(b in known[s] for b in tokens), s) for s in CLASSES), key=lambda x: (-x[0], x[1]))
    if p[0][0] == 0 or p[0][0] == p[1][0]:
        return "ambiguous"
    r = rng(seed + 1009 * width + 7 * t["id"])
    return p[1][1] if r() < 0.14 else p[0][1]


def measure(candidate, seed=SEED):
    name, width, multiplier, output, example, outside = candidate
    correct = calls = inputs = 0
    for t in REQUESTS:
        n = len(tokenize(t["text"]))
        y, calls, inputs = call(t, width, seed), calls + 1, inputs + n
        if y == "ambiguous" and example:        # scaffolding: add two examples to the prompt and ask again
            y, calls, inputs = call(t, width + 1, seed), calls + 1, inputs + n + example
        correct += y == t["label"]
    calls, inputs = calls / 24, inputs / 24
    return {"name": name, "hit_rate": correct / 24, "equivalent": (inputs + calls * output) * multiplier,
            "steps": calls * output, "calls": calls, "outside": outside}


S = [measure(c) for c in CANDIDATES]
print("candidate  hit rate  hit rate B  equivalent tokens  steps  calls/request  crossing")
for c, s in zip(CANDIDATES, S):
    b = measure(c, SEED + 1)["hit_rate"]
    print(f"{s['name']:<11}{s['hit_rate']:<10.4f}{b:<12.4f}{s['equivalent']:<19.1f}"
          f"{s['steps']:<7.2f}{s['calls']:<15.4f}{s['outside']}/24")
```

```
candidate  hit rate  hit rate B  equivalent tokens  steps  calls/request  crossing
small      0.5000    0.5833      89.5               7.29   1.4583         0/24
medium     0.5833    0.6667      195.3              7.00   1.0000         0/24
large      0.7917    0.7083      670.0              14.00  1.0000         24/24
```

The four columns show four different winners. On hit rate the **large** candidate leads: 0.7917. On
equivalent tokens the **small** candidate leads: 89.5, that is, less than a seventh of the large one's
670.0. On steps the **medium** candidate leads, but the margin is thin: 7.00 against 7.29. On requests
crossing the organization boundary the small and medium candidates lead together, because both run on
their own hardware.

Where the small candidate loses on the number of steps is written in the calls column: **1.4583** calls
per request. This is the cost of the scaffolding. For responses that fail the format check, two examples
are added to the prompt and the request is asked again; the hit rate rises to 0.5000 this way, but every
request that is asked again adds one more step. The scaffolding takes the hit rate and gives up latency,
and both numbers stand side by side in the table.

Attention must be paid to what the equivalent token unit is. The three candidates receive the same
request and read almost the same number of tokens; by raw token count all three are close to one another.
The difference lies not in the **number** of the token but in what it **holds**: processing the same token
means twelve times the computational load on the large candidate compared with the small one. The unit
multiplier reduces this load to a single number and makes the comparison possible. This is also the reason
no currency is written: the multiplier is structural and reads the same way five years from now, whereas a
price list does not. The output tokens are counted separately, because the output is produced
sequentially and enters both the cost and the steps; a 14-token response from the large candidate means
twice the latency of the small candidate, which gives the same response in 5 tokens.

## When the Weight Changes, the Winner Changes

```python
CRITERIA = [("hit rate", "hit_rate", 1), ("equivalent tokens", "equivalent", -1), ("steps", "steps", -1),
            ("requests crossing", "outside", -1)]


def normalize(results, key, direction):
    """Single normalization rule: in the range the worst is 0, the best is 1."""
    v = [s[key] for s in results]
    lo, hi = min(v), max(v)
    if hi == lo:
        return [1.0] * len(v)
    return [((x - lo) if direction > 0 else (hi - x)) / (hi - lo) for x in v]


N = {k: normalize(S, k, d) for _, k, d in CRITERIA}
WEIGHTS = [("balanced", (1, 1, 1, 1)), ("hit-rate weighted", (4, 1, 1, 1)),
           ("cost weighted", (1, 4, 1, 1)), ("latency weighted", (1, 1, 4, 1)),
           ("data-boundary weighted", (1, 1, 1, 4))]
print(f"{'criterion':<20}{'small':<8}{'medium':<8}large")
for name, k, _ in CRITERIA:
    print(f"{name:<20}{N[k][0]:<8.4f}{N[k][1]:<8.4f}{N[k][2]:.4f}")
print(f"{'weight set':<24}{'small':<8}{'medium':<8}{'large':<8}winner")
for name, w in WEIGHTS:
    p = [sum(w[j] * N[k][i] for j, (_, k, _) in enumerate(CRITERIA)) for i in range(3)]
    print(f"{name:<24}{p[0]:<8.4f}{p[1]:<8.4f}{p[2]:<8.4f}{S[p.index(max(p))]['name']}")
```

```
criterion           small   medium  large
hit rate            0.0000  0.2857  1.0000
equivalent tokens   1.0000  0.8176  0.0000
steps               0.9583  1.0000  0.0000
requests crossing   1.0000  1.0000  0.0000
weight set              small   medium  large   winner
balanced                2.9583  3.1033  1.0000  medium
hit-rate weighted       2.9583  3.9605  4.0000  large
cost weighted           5.9583  5.5562  1.0000  small
latency weighted        5.8333  6.1033  1.0000  medium
data-boundary weighted  5.9583  6.1033  1.0000  medium
```

Across five weight sets, three different winners come out. With equal weights the **medium** candidate
wins, and the margin is small: 3.1033 against 2.9583. When the hit-rate weight is raised to four, the
**large** candidate moves ahead, but again by a hair: 4.0000 against 3.9605. When the cost weight is
raised to four, the **small** candidate wins. The latency and data-boundary weights do nothing other than
enlarge the medium candidate.

The same three candidates, the same four numbers, three different decisions. The only thing that changes
is which criterion counts for how much. For this reason the question "which model is better" has no
measure; the question that has a measure is "with these four numbers and these weights, which candidate
wins". A justification for a choice, as long as it does not write its weights, is not a justification but
a preference.

The normalization rule is itself a decision, and it does not show in the table. The rule used here scales
to the range: in each criterion the worst candidate gets 0 and the best gets 1. This rule does not look
at how large the difference in between is — on hit rate the 0.0833 difference between small and medium
is divided by the 0.2917 width of the range and rises to 0.2857, that is, a small absolute difference
turns into a large normalized difference. Another rule would have given other numbers. Because the
Environment Selection lesson measured that this can turn the choice, a single rule is used here and the
fact that it is used is written; when it is not written, the table looks more objective than it is.

## The Threshold and the Choice's Own Uncertainty

```python
def winner(N, w):
    p = [sum(w[j] * N[k][i] for j, (_, k, _) in enumerate(CRITERIA)) for i in range(3)]
    return CANDIDATES[p.index(max(p))][0]


print(f"{'swept criterion':<20}{'weight 1':<10}{'first change':<14}new winner")
for j, (name, _, _) in enumerate(CRITERIA):
    before, threshold, new = winner(N, (1, 1, 1, 1)), "-", "-"
    for w in range(2, 13):
        k = winner(N, tuple(w if i == j else 1 for i in range(4)))
        if k != before and threshold == "-":
            threshold, new = w, k
    print(f"{name:<20}{before:<10}{str(threshold):<14}{new}")
S2 = [measure(c, SEED + 1) for c in CANDIDATES]
N2 = {k: normalize(S2, k, d) for _, k, d in CRITERIA}
print(f"{'weight set':<24}{'seed 20260218':<16}seed 20260219")
for name, w in WEIGHTS:
    print(f"{name:<24}{winner(N, w):<16}{winner(N2, w)}")
```

```
swept criterion     weight 1  first change  new winner
hit rate            medium    4             large
equivalent tokens   medium    2             small
steps               medium    -             -
requests crossing   medium    -             -
weight set              seed 20260218   seed 20260219
balanced                medium          medium
hit-rate weighted       large           medium
cost weighted           small           small
latency weighted        medium          medium
data-boundary weighted  medium          medium
```

The thresholds are asymmetric. When the cost weight is **2**, the choice moves from medium to small; the
hit-rate weight does not bring the large candidate ahead until it reaches **4**. Even when the latency
and data-boundary weights are swept up to twelve, they do not change the choice, because on these two
criteria the winner is already medium and raising the weight does nothing other than reinforce the
winner. Enlarging the weight of a criterion never brings ahead a candidate that is not ahead on that
criterion.

The last table is the most uncomfortable number of this lesson. When the same computation is repeated
with seed 20260219, **the winner of the hit-rate weighted set turns from large back to medium**. No
criterion changed, no weight changed; only the sampling seed of the simulator changed, and the hit rate
of the large candidate fell from 0.7917 to 0.7083. A decision won by a hair, when it is built on a
component that changes from run to run, becomes dependent on the run itself. In three of the four
criteria the choice is solid; in the fourth it is not, and this is something a comparison measured with a
single run could never see.

A practical rule follows from this. A selection table should write not only the winner but also the
**winning margin**. Rows with a wide margin stand even if the weights wobble a little or the measurement
drifts a little; those with a thin margin do not. The margin of 0.1450 between 3.1033 and 2.9583 at equal
weights and the margin of 0.0395 between 4.0000 and 3.9605 in the hit-rate weighted set stand in the same
table but do not say the same thing. In a row with a thin margin the right behavior is not to declare the
winner but to run both candidates again by enlarging the measurement, or to tie the decision to one more
criterion.

## Summary

- When three candidates are counted on four criteria the winner splits four ways: large on hit rate with
  0.7917, small on equivalent tokens with 89.5, medium on steps with 7.00, small and medium on requests
  crossing the organization boundary with 0/24.
- The small candidate's scaffolding raises the hit rate to 0.5000 while taking the number of calls to
  1.4583 and the steps to 7.29; the gain and the cost stand in the same table.
- Across five weight sets the winner is medium three times, large once and small once; at equal weights
  the margin is thin, like 3.1033 against 2.9583.
- The thresholds are asymmetric: a cost weight of 2 turns the choice from medium to small, and a hit-rate
  weight of 4 turns it from medium to large; the latency and data-boundary weights do not change the choice
  up to twelve.
- With seed 20260219 the winner of the hit-rate weighted set turns from large back to medium; a choice
  given by a hair becomes dependent on the run.

## Next Step

In this lesson cost was counted as a criterion, but it was never used as a limit. The winning candidate
was chosen freely under every weight set; yet in a real system the token budget is an upper bound and
imposes not the choice but the **design**. Even with the same task, the same model and the same hit rate,
how many tokens are spent per request varies greatly with how the prompt is written. The next lesson
compares four scaffoldings as tokens and hit rate — a long prompt, a short prompt with examples,
summarize and send, and a cache — and shows which design becomes mandatory when the budget narrows. That
lesson is also the close of the course.
