Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close