Lesson 02 / 14
Numerical Representation
Counting what each decision adds when short field notes are turned into columns, with the same model and the same held-out set: while the domain baseline of five hand-counted numbers gives 0.7383 and the most frequent class stays at 0.7017, a bag of words of 96 stems reaches 0.8217 and passes the line by 0.0833. Vocabulary size adds contribution from 8 to 64 and adds exactly zero from 64 to 96. Raw counts and inverse document frequency weighting do not move the accuracy at all (0.0833), dropping stop words loses 0.0067, and the ceiling that knows the event kind is 0.8250 — the bag is 0.0033 below the ceiling.
Published Updated
In this lesson
- Measures what a bag of words adds to hand-counted text features with the same model and the same held-out set, and shows that the source of the difference is the representation
- Sweeps vocabulary size over six candidates and counts where the contribution saturates and where the training score keeps rising
- Measures what term weighting and dropping stop words add, and compares the result with the ceiling of the task
Contents
The previous lesson split the text into tokens and brought the vocabulary down to 96 stems. What
came out was still a list of words; not a single number that a model would see had been produced.
This lesson turns the vocabulary into columns: every stem becomes a column, and whether it occurs
in the note becomes the value of that column. The name of the procedure is bag of words, because
the order is discarded — muhur kirik (seal broken) and kirik muhur (broken seal) give the same
row.
The line of the measure is ready. The Data Preparation and Feature Engineering course had extracted five numbers by hand from the same note field, and those five numbers had added 0.0317 on the held-out set to a decision tree. The domain baseline is those five numbers: the cheapest way of extracting numbers from a text. How far above this line does the bag of words go, what does the contribution do as the vocabulary grows, and what does term weighting put on top.
- NL9. The setup, the set and the seed are as in
nlp/01. The tokenization decision is the third decision of 01 — dropping punctuation, lowercasing, stemming, number folding — and it is fixed in this lesson. - NL10. The task is the binary prediction of illicit-use suspicion. The metric is the accuracy on the held-out set (larger is better), and the recall of the suspicious class is written next to it. The metric definitions were set up in the Supervised Learning course and are not repeated.
- NL11. All representations use the same model: a single-layer linear model, full-batch gradient descent, 120 rounds, step 1.5, threshold zero. Because the model and the metric are fixed, the difference can come only from the representation.
- NL12. Every step of the pipeline is learned from the training set only: the vocabulary, the document frequency, the mean and the standard deviation. The rule of the Data Preparation and Feature Engineering course continues.
- NL13. The domain baseline is five hand-counted numbers: note length, word count and three keyword indicators (seal, access, vehicle). The first two are scaled with the mean and standard deviation of the training set. The most frequent class is the absolute baseline, and its recall is zero.
- NL14. The vocabulary size sweep is six candidates: 8, 16, 32, 48, 64, 96. The vocabulary is taken in order from the most frequent stems of the training set.
- NL15. Term weighting is named by its form: raw count, and the count multiplied by the inverse document frequency — the second is brought to unit length per document.
- NL16. The ceiling is the number of a procedure that knows the event kind on the same metric; since the label is generated from the kind alone, this is the highest number that can be read from the text.
Setup
The notes are regenerated from the same seed as the setup of nlp/01.
# COMMON — FICTIONAL. The field notes are generated within the lesson, seed 20260218; # they do not come from a source system. The vocabulary has 45 inflected roots and # 40 rare nouns. SEED, NOTE_COUNT, M32 = 20260218, 1800, 0xFFFFFFFF NOUNS = { # 0 base, 1 genitive, 2 locative, 3 possessive, 4 plural "sayac": "sayac sayacin sayacta sayaci sayaclar", "kapak": "kapak kapagin kapakta kapagi kapaklar", "muhur": "muhur muhurun muhurde muhuru muhurler", "vana": "vana vananin vanada vanasi vanalar", "kelepce": "kelepce kelepcenin kelepcede kelepcesi kelepceler", "boru": "boru borunun boruda borusu borular", "pano": "pano panonun panoda panosu panolar", "kapi": "kapi kapinin kapida kapisi kapilar", "zil": "zil zilin zilde zili ziller", "bahce": "bahce bahcenin bahcede bahcesi bahceler", "apartman": "apartman apartmanin apartmanda apartmani apartmanlar", "bodrum": "bodrum bodrumun bodrumda bodrumu bodrumlar", "giris": "giris girisin giriste girisi girisler", "adres": "adres adresin adreste adresi adresler", "okuma": "okuma okumanin okumada okumasi okumalar", "olcum": "olcum olcumun olcumde olcumu olcumler", "erisim": "erisim erisimin erisimde erisimi erisimler", "ziyaret": "ziyaret ziyaretin ziyarette ziyareti ziyaretler", "abone": "abone abonenin abonede abonesi aboneler", "arac": "arac aracin aracta araci araclar", "kir": "kir kirin kirde kiri kirler", "iz": "iz izin izde izi izler", "sokak": "sokak sokagin sokakta sokagi sokaklar", "ev": "ev evin evde evi evler", } ADJECTIVES = "kirik gevsek kirli normal bozuk acik kapali eksik temiz yeni yok".split() VERBS = { # 0 passive past, 1 negative "calis": "calisiyor calismiyor", "don": "donuyor donmuyor", "oku": "okundu okunmadi", "ac": "acildi acilmadi", "gir": "girildi girilemedi", "ulas": "ulasildi ulasilamadi", "tekrarla": "tekrarlandi tekrarlanmadi", "bul": "bulundu bulunmadi", "sagla": "saglandi saglanamadi", "degil": "degil degildi", } RARE_NOUNS = """kablo kablosu kabloda|cam cami camda|tel teli telde|mandal mandali mandalda merdiven merdiveni merdivende|tahta tahtasi tahtada|cukur cukuru cukurda|kar kari karda buz buzu buzda|cop copu copta|duvar duvari duvarda|kutu kutusu kutuda|vida vidasi vidada conta contasi contada|filtre filtresi filtrede|bant banti bantta|etiket etiketi etikette kilit kiliti kilitte|asansor asansoru asansorde|zemin zemini zeminde|tavan tavani tavanda kuyu kuyusu kuyuda|depo deposu depoda|ocak ocagi ocakta|direk diregi direkte kanal kanali kanalda|numara numarasi numarada|cati catisi catida|baca bacasi bacada kova kovasi kovada|levha levhasi levhada|somun somunu somunda|tapa tapasi tapada izolasyon izolasyonu izolasyonda|supap supabi supapta|menfez menfezi menfezde kayit kaydi kayitta|temel temeli temelde|kose kosesi kosede|koruma korumasi korumada""" PHRASES = { "ordinary": ["{sayac.0} {calis.0}", "{okuma.0} normal", "{olcum.0} {tekrarla.0}", "{sayac.1} {erisim.3} {sagla.0}", "{kapak.0} temiz", "{vana.0} acik", "{sayac.2} {iz.0} {bul.1}", "{olcum.0} icin {erisim.0} {sagla.0}", "{sayac.1} {kapak.3} yeni", "{muhur.0} ile {kapak.0} temiz"], "fault": ["{sayac.0} {don.1}", "{kapak.0} kirik", "{pano.0} bozuk", "{sayac.1} {kapak.3} kirik", "{olcum.0} {oku.1}", "{vana.0} bozuk", "{sayac.2} {kir.0} {bul.0}", "{sayac.4} {calis.1}", "{sayac.0} {calis.0} ama {okuma.0} yok", "{pano.1} {kapak.3} eksik"], "unreachable": ["{kapi.0} {ac.1}", "{zil.0} {calis.1}", "{abone.0} {ev.2} {degil.1}", "{erisim.0} {sagla.1}", "{bodrum.0} {kapi.3} kapali", "{apartman.1} {giris.3} kapali", "{adres.2} {ulas.1}", "{bahce.1} {kapi.3} kapali", "{apartman.2} {zil.0} {calis.1}", "{sayac.2} {erisim.0} yok"], "illicit": ["{muhur.0} kirik", "{kelepce.0} gevsek", "{muhur.2} {iz.0} {bul.0}", "{sayac.1} {muhur.3} kirik", "{kelepce.2} {iz.0} {bul.0}", "{muhur.0} eksik", "{vana.1} {kelepce.3} gevsek", "{muhur.1} {iz.3} {bul.0}", "{boru.2} {kelepce.0} eksik", "{kapak.2} bir {iz.0} {bul.0}"], } EXTRA_PHRASES = ["{ziyaret.0} {tekrarla.0}", "{sokak.2} {arac.0} {bul.0}", "{adres.0} yeni", "{bahce.1} {kapi.3} acik", "{apartman.2} {gir.0}", "{ev.1} {kapi.3} acik", "{kapak.0} kirli", "{olcum.0} {tekrarla.1}", "{arac.0} {sokak.2}", "bu {ziyaret.2} {olcum.0} {tekrarla.0}"] KINDS = [("ordinary", 0.36, 0.04), ("fault", 0.26, 0.28), ("unreachable", 0.16, 0.15), ("illicit", 0.22, 0.82)] FORMS = {k: v.split() for d in (NOUNS, VERBS) for k, v in d.items()} RARE = [s.split() for s in RARE_NOUNS.replace("\n", "|").split("|")] WEIGHTS = [1 / (i + 1) ** 1.6 for i in range(len(RARE))] WEIGHTS = [w / sum(WEIGHTS) for w in WEIGHTS] ROOT = {f: k for k, fs in FORMS.items() for f in fs} ROOT.update({f: fs[0] for fs in RARE for f in fs}) ROOT.update({w: w for w in ADJECTIVES}) STOP_WORDS = ["ve", "ile", "bir", "bu", "ama", "icin"] ROOT.update({w: w for w in STOP_WORDS}) 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 pick(u, w): t = 0.0 for i, x in enumerate(w): t += x if u < t: return i return len(w) - 1 def render_phrase(template): parts = [] for part in template.split(): if part.startswith("{"): name, _, i = part[1:-1].partition(".") parts.append(FORMS[name][int(i)]) else: parts.append(part) return " ".join(parts) DATA = [] for i in range(NOTE_COUNT): r = rng(SEED + i) kind = KINDS[pick(r(), [x[1] for x in KINDS])] clauses = [render_phrase(PHRASES[kind[0]][int(r() * 10)])] if r() < 0.55: clauses.append(render_phrase(EXTRA_PHRASES[int(r() * 10)])) if r() < 0.30: clauses.append(render_phrase(PHRASES[kind[0]][int(r() * 10)])) if r() < 0.22: forms, adjective = RARE[pick(r(), WEIGHTS)], ADJECTIVES[int(r() * len(ADJECTIVES))] clauses.append(forms[2] + " kir bulundu" if r() < 0.34 else forms[int(r() * 2)] + " " + adjective) note = clauses[0] for x in clauses[1:]: note += (" ve " if r() < 0.22 else ", ") + x if r() < 0.52: note += ", okuma " + str(240 + int(r() * 1760)) DATA.append({"id": 10001 + i, "note": note[0].upper() + note[1:] + ".", "kind": kind[0], "suspicious": int(r() < kind[2])}) TRAIN, HELD_OUT = DATA[:1200], DATA[1200:] print(f"{len(DATA)} fictional notes, {len(FORMS) + len(RARE) + len(ADJECTIVES)} roots, " f"suspicion rate {sum(x['suspicious'] for x in DATA) / len(DATA):.4f}") print(DATA[3]["note"])
1800 fictional notes, 85 roots, suspicion rate 0.2956 Olcum icin erisim saglandi, sayacin kapagi yeni, mandalda kir bulundu.
Bag of Words and Vocabulary Size
The model is written once and stays the same on every row. The features are given sparsely: because a note contains a dozen or so stems, a few multiplications per row are enough even when the column count goes up to 96.
import math from collections import Counter SUFFIXES = ["lari", "leri", "lar", "ler", "nin", "nun", "dan", "den", "tan", "ten", "dir", "tir", "dur", "da", "de", "ta", "te", "in", "un", "yi", "yu", "si", "su", "li", "lu", "di", "du", "ti", "tu", "i", "u", "a", "e"] def stem(token): for suffix in SUFFIXES: if token.endswith(suffix) and len(token) - len(suffix) >= 3: return token[:-len(suffix)] return token for x in DATA: # third decision of nlp/01: drop punctuation, lowercase, stem, fold numbers x["tokens"] = [("<number>" if t.isdigit() else stem(t)) for t in (s.strip(",.").lower() for s in x["note"].split()) if t] def fit_model(features, y, size, rounds=120, step=1.5): w = [0.0] * (size + 1) for _ in range(rounds): grad = [0.0] * (size + 1) for x, target in zip(features, y): z = w[size] + sum(w[i] * v for i, v in x) err = 1 / (1 + math.exp(-max(-30.0, min(30.0, z)))) - target for i, v in x: grad[i] += err * v grad[size] += err for i in range(size + 1): w[i] -= step * grad[i] / len(y) return w def evaluate(w, features, y, size): correct = caught = 0 for x, truth in zip(features, y): pred = int(w[size] + sum(w[i] * v for i, v in x) > 0) correct, caught = correct + (pred == truth), caught + (pred == 1 and truth == 1) return correct / len(y), caught / sum(y) def run(name, build, size): # the first call sets the line y_train, y_held = [x["suspicious"] for x in TRAIN], [x["suspicious"] for x in HELD_OUT] w = fit_model([build(x) for x in TRAIN], y_train, size) acc_train, _ = evaluate(w, [build(x) for x in TRAIN], y_train, size) acc_held, recall_held = evaluate(w, [build(x) for x in HELD_OUT], y_held, size) if not BASELINE: BASELINE.append(acc_held) print(f"{name:<26} {size:>7} {acc_train:>9.4f} {acc_held:>9.4f} {recall_held:>10.4f}" f" {acc_held - BASELINE[0]:>+8.4f}") return acc_held KEYWORDS, BASELINE = ["muhur", "erisim", "arac"], [] raw = [[len(x["note"]), len(x["tokens"])] for x in TRAIN] MEAN = [sum(r[i] for r in raw) / len(raw) for i in (0, 1)] STD = [math.sqrt(sum((r[i] - MEAN[i]) ** 2 for r in raw) / len(raw)) for i in (0, 1)] def by_hand(x): # the five numbers of the Data Preparation and Feature Engineering course o = [(0, (len(x["note"]) - MEAN[0]) / STD[0]), (1, (len(x["tokens"]) - MEAN[1]) / STD[1])] for i, keyword in enumerate(KEYWORDS): o.append((2 + i, float(any(t.startswith(keyword) for t in x["tokens"])))) return o MAJORITY = sum(x["suspicious"] == 0 for x in HELD_OUT) / len(HELD_OUT) print(f"{'representation':<26} {'columns':>7} {'train':>9} {'held-out':>9} {'recall':>10}" f" {'vs line':>8}") run("hand-counted five numbers", by_hand, 5) print(f"{'most frequent class':<26} {0:>7} {'':>9} {MAJORITY:>9.4f} {0.0:>10.4f}" f" {MAJORITY - BASELINE[0]:>+8.4f}") FREQ = Counter(t for x in TRAIN for t in x["tokens"]) RANKED = sorted(FREQ, key=lambda t: (-FREQ[t], t)) DOC_FREQ = Counter(t for x in TRAIN for t in set(x["tokens"])) STOP_STEMS = {stem(w) for w in STOP_WORDS} def bag(terms, mode="indicator"): index = {t: i for i, t in enumerate(terms)} idf = {t: math.log(len(TRAIN) / DOC_FREQ.get(t, 1)) for t in index} def build(x): counts = Counter(t for t in x["tokens"] if t in index) if mode == "indicator": return [(index[t], 1.0) for t in counts] if mode == "count": return [(index[t], float(v)) for t, v in counts.items()] norm = math.sqrt(sum((v * idf[t]) ** 2 for t, v in counts.items())) or 1.0 return [(index[t], v * idf[t] / norm) for t, v in counts.items()] return build print() for n in (8, 16, 32, 48, 64, 96): run(f"bag of words {n}", bag(RANKED[:n]), n)
representation columns train held-out recall vs line hand-counted five numbers 5 0.7608 0.7383 0.3408 +0.0000 most frequent class 0 0.7017 0.0000 -0.0367 bag of words 8 8 0.7783 0.7667 0.3296 +0.0283 bag of words 16 16 0.7908 0.7700 0.3911 +0.0317 bag of words 32 32 0.8208 0.8067 0.5140 +0.0683 bag of words 48 48 0.8300 0.8133 0.5363 +0.0750 bag of words 64 64 0.8333 0.8217 0.5698 +0.0833 bag of words 96 96 0.8350 0.8217 0.5698 +0.0833
The line is 0.7383, and it passes the most frequent class by 0.0367. In the Data Preparation and Feature Engineering course, the same five numbers had added 0.0317 to a decision tree; the closeness of the two numbers is not a coincidence, because the source of the contribution in both setups is the same three keyword indicators. The recall column shows why the baseline has to be read: while the most frequent class gives an accuracy of 0.7017, it finds none of the suspicious ones.
The bag of words passes the line by 0.0833 — more than twice what the five hand-counted numbers added. Even eight columns add 0.0283, which means the eight most frequent stems do about the same work as five hand-picked indicators. The contribution saturates at 64 columns: between 64 and 96 the held-out score stays exactly the same while the training score rises from 0.8333 to 0.8350. Adding columns always raises the training score; this is not a measurement but a requirement of the definition.
Weighting and the Ceiling
What is written into the cell of the bag is a separate decision: a presence indicator, a raw count, or the frequency weighted by its sparsity across documents.
run("count weighting", bag(RANKED, "count"), len(RANKED)) run("inverse document frequency", bag(RANKED, "weighted"), len(RANKED)) KEPT = [t for t in RANKED if t not in STOP_STEMS] run("stop words removed", bag(KEPT), len(KEPT)) KIND_LABEL = {} for name, _, _ in KINDS: p = [x["suspicious"] for x in TRAIN if x["kind"] == name] KIND_LABEL[name] = int(sum(p) * 2 > len(p)) ceiling = sum(KIND_LABEL[x["kind"]] == x["suspicious"] for x in HELD_OUT) / len(HELD_OUT) print(f"\n{'ceiling (event kind known)':<26} {'':>7} {'':>9} {ceiling:>9.4f} {'':>10}" f" {ceiling - BASELINE[0]:>+8.4f}")
count weighting 96 0.8333 0.8217 0.5698 +0.0833 inverse document frequency 96 0.8150 0.8217 0.5363 +0.0833 stop words removed 90 0.8292 0.8150 0.5419 +0.0767 ceiling (event kind known) 0.8250 +0.0867
Term weighting adds nothing in this corpus: all three rows give 0.8217. The reason lies in the text itself. The notes are short, and a stem almost always occurs once in a note, so a raw count cannot be told apart from an indicator column. Inverse document frequency weighting rescales the columns but does not break the ordering; while the accuracy stays the same, the recall falls from 0.5698 to 0.5363. The difference it adds is 0.0000.
Dropping stop words loses 0.0067. Three of the six stop words are markers of particular
patterns in this setup: ile (with) occurs only in the ordinary phrase, ama (but) only in the
fault phrase.
The stop word list is read from the task, not from the language; a general list throws away
information here.
The last row closes the remaining gap. A procedure that knows the event kind gives 0.8250, and the bag of words, at 0.8217, is 0.0033 below it. Since the label is generated from the kind alone, this is the highest number that can be read from the text: a better representation cannot win more than 0.0033 in this setup. The source of the difference can be written down as well — the model is the same on every row, the metric and the held-out set are the same, and the difference comes from the representation.
Summary
- The domain baseline is five hand-counted numbers and it gives 0.7383; the most frequent class stays at 0.7017 and finds none of the suspicious ones.
- The bag of words of 96 stems gives 0.8217 and passes the line by 0.0833 — more than twice what the five hand-counted numbers added. Because the model and the metric are fixed, the whole difference comes from the representation.
- Vocabulary size adds contribution from 8 to 64 and saturates there: while the difference between 64 and 96 is exactly zero, the training score rises from 0.8333 to 0.8350.
- Term weighting adds 0.0000; because the notes are short, the count cannot be told apart from the indicator, and inverse document frequency weighting moves the recall rather than the accuracy (from 0.5698 to 0.5363). Dropping stop words loses 0.0067, because three words on the list are pattern markers in this setup.
- The ceiling that knows the event kind is 0.8250; the bag is 0.0033 below the ceiling, and the remaining gap is not in the text.
Next Step
The bag of words turned a note into 96 columns, but the columns are entirely strangers to one
another: the muhur (seal) and kelepce (clamp) columns are as far apart as the muhur and
<number> columns. All the model learns is the relation of each column to the label, and this
relation is learned from scratch for every column. Yet in the notes muhur and kelepce occur next
to similar words. The
next lesson uses this: every stem is represented by a vector instead of a number, and the vector is
learned from the context. The measure is whether the learned neighborhood turns out to be
meaningful and in how many cases the vector arithmetic holds.
To keep your progress and take notes, Log in
My notes
Log in to take notes.