summaryrefslogtreecommitdiff
path: root/worldalign/natural_families.py
blob: a13a1d9739433dd06f0c923a3ba14866e07aeb75 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
"""Do factor families survive real language?

The synthetic derivation found colour, count, and size families because
templated phrases place exactly one member of each family in a fixed
slot, so family members never co-occur. Real descriptions break every
part of that: free word order, stacked adjectives, synonyms, and phrases
that mention no attribute at all. This measures how much of the
mutual-exclusivity signal survives, on Visual Genome region descriptions,
using no lexicon and no labels.

Families are recovered as low-co-occurrence, high-context-similarity
groups: two words of one factor rarely modify the same head, and when
they do appear they appear in the same distributional company. That is
the paradigmatic relation of distributional semantics, and the greedy
exclusivity pass of the synthetic pipeline is its degenerate case.
"""

from __future__ import annotations

import argparse
import json
import re
from collections import Counter, defaultdict
from pathlib import Path

import numpy as np

from .common import read_json, seed_everything, write_json

# Reference sets, used only to score the recovered families, never to find
# them. Membership is checked after the fact.
REFERENCE = {
    "colour": {
        "black", "white", "red", "blue", "green", "yellow", "brown", "gray",
        "grey", "orange", "purple", "pink", "tan", "beige", "silver", "gold",
        "golden", "dark", "light",
    },
    "number": {
        "one", "two", "three", "four", "five", "six", "seven", "eight",
        "nine", "ten", "a", "an", "the", "some", "many",
    },
    "size": {"small", "large", "big", "little", "tiny", "huge", "tall", "short", "long"},
    "material": {"wooden", "metal", "plastic", "glass", "brick", "stone", "leather"},
}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--nodes", default="artifacts/vg_5k/text_nodes.jsonl")
    parser.add_argument("--field", default="region_closed")
    parser.add_argument("--min-count", type=int, default=200)
    parser.add_argument("--max-vocabulary", type=int, default=300)
    parser.add_argument("--context-window", type=int, default=2)
    parser.add_argument("--exclusivity", type=float, default=0.25,
                        help="Maximum observed-over-expected co-occurrence for "
                             "two words to count as mutually exclusive.")
    parser.add_argument(
        "--method",
        choices=["exclusivity", "distributional"],
        default="distributional",
        help="Mutual exclusivity is a template artifact; real paradigms are "
        "found by distributional similarity, of which it is a special case.",
    )
    parser.add_argument("--components", type=int, default=64)
    parser.add_argument("--clusters", type=int, default=30)
    parser.add_argument("--min-family", type=int, default=3)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--output", default="artifacts/vg_5k/natural_families.json"
    )
    return parser.parse_args()


def load_phrases(path: Path, field: str) -> list[list[str]]:
    phrases = []
    for line in path.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        record = json.loads(line)
        for phrase in record[field]:
            tokens = re.findall(r"[a-z]+", phrase.lower())
            if tokens:
                phrases.append(tokens)
    return phrases


def statistics(
    phrases: list[list[str]], vocabulary: set[str], window: int
) -> tuple[Counter, Counter, dict[str, Counter]]:
    unigram: Counter = Counter()
    pair: Counter = Counter()
    context: dict[str, Counter] = defaultdict(Counter)
    for tokens in phrases:
        present = [token for token in tokens if token in vocabulary]
        for token in set(present):
            unigram[token] += 1
        for first in set(present):
            for second in set(present):
                if first < second:
                    pair[(first, second)] += 1
        for index, token in enumerate(tokens):
            if token not in vocabulary:
                continue
            for offset in range(1, window + 1):
                for neighbour_index in (index - offset, index + offset):
                    if 0 <= neighbour_index < len(tokens):
                        context[token][tokens[neighbour_index]] += 1
    return unigram, pair, context


def exclusivity_ratio(
    first: str, second: str, unigram: Counter, pair: Counter, total: int
) -> float:
    """Observed co-occurrence over the independent expectation."""
    expected = unigram[first] * unigram[second] / max(total, 1)
    key = (first, second) if first < second else (second, first)
    return pair[key] / max(expected, 1e-9)


def context_similarity(first: Counter, second: Counter) -> float:
    keys = set(first) | set(second)
    a = np.array([first[k] for k in keys], dtype=float)
    b = np.array([second[k] for k in keys], dtype=float)
    a /= max(np.linalg.norm(a), 1e-9)
    b /= max(np.linalg.norm(b), 1e-9)
    return float(a @ b)


def build_families(
    words: list[str],
    unigram: Counter,
    pair: Counter,
    context: dict[str, Counter],
    total: int,
    args: argparse.Namespace,
) -> list[list[str]]:
    """Greedy paradigmatic grouping: exclusive and distributionally alike."""
    families: list[list[str]] = []
    for word in words:
        best_family, best_score = None, 0.0
        for family in families:
            ratios = [
                exclusivity_ratio(word, member, unigram, pair, total)
                for member in family
            ]
            if max(ratios) > args.exclusivity:
                continue
            similarity = float(
                np.mean(
                    [context_similarity(context[word], context[member])
                     for member in family]
                )
            )
            if similarity > best_score:
                best_family, best_score = family, similarity
        if best_family is not None and best_score > 0.15:
            best_family.append(word)
        else:
            families.append([word])
    return [family for family in families if len(family) >= args.min_family]


def distributional_families(
    words: list[str], context: dict[str, Counter], args: argparse.Namespace
) -> list[list[str]]:
    """Word classes from context vectors: positive PMI, truncated SVD, k-means.

    The standard recipe of distributional word-class induction. Words of
    one factor modify the same heads and so share company, whether or not
    they exclude each other -- real colour terms co-occur freely.
    """
    from sklearn.cluster import KMeans

    features = sorted({key for word in words for key in context[word]})
    index = {key: position for position, key in enumerate(features)}
    matrix = np.zeros((len(words), len(features)))
    for row, word in enumerate(words):
        for key, value in context[word].items():
            matrix[row, index[key]] = value
    total = matrix.sum()
    row_sum = matrix.sum(1, keepdims=True)
    column_sum = matrix.sum(0, keepdims=True)
    expected = row_sum * column_sum / max(total, 1e-9)
    pmi = np.log(np.maximum(matrix, 1e-9) / np.maximum(expected, 1e-9))
    pmi[matrix == 0] = 0.0
    pmi = np.maximum(pmi, 0.0)
    left, values, _ = np.linalg.svd(pmi, full_matrices=False)
    embedding = left[:, : args.components] * values[: args.components]
    embedding /= np.linalg.norm(embedding, axis=1, keepdims=True).clip(1e-9)
    labels = KMeans(args.clusters, n_init=10, random_state=args.seed).fit_predict(
        embedding
    )
    families: dict[int, list[str]] = defaultdict(list)
    for word, label in zip(words, labels):
        families[int(label)].append(word)
    return [family for family in families.values() if len(family) >= args.min_family]


def label_family(family: list[str]) -> tuple[str, float]:
    best_name, best_purity = "unlabelled", 0.0
    for name, reference in REFERENCE.items():
        purity = sum(1 for word in family if word in reference) / len(family)
        if purity > best_purity:
            best_name, best_purity = name, purity
    return best_name, best_purity


def main() -> None:
    args = parse_args()
    seed_everything(args.seed)
    phrases = load_phrases(Path(args.nodes), args.field)
    counts = Counter(token for tokens in phrases for token in set(tokens))
    vocabulary = [
        word
        for word, count in counts.most_common(args.max_vocabulary)
        if count >= args.min_count
    ]
    unigram, pair, context = statistics(phrases, set(vocabulary), args.context_window)
    if args.method == "distributional":
        families = distributional_families(vocabulary, context, args)
    else:
        families = build_families(
            vocabulary, unigram, pair, context, len(phrases), args
        )

    described = []
    for family in sorted(families, key=len, reverse=True):
        name, purity = label_family(family)
        described.append(
            {
                "size": len(family),
                "label": name,
                "purity": purity,
                "words": sorted(family, key=lambda w: -unigram[w])[:14],
            }
        )
    report = {
        "protocol": (
            "Families are recovered from region descriptions alone by "
            "mutual exclusivity plus distributional similarity. Reference "
            "word sets are read only to label the result."
        ),
        "phrases": len(phrases),
        "vocabulary": len(vocabulary),
        "families": described,
        "recovered_labels": Counter(item["label"] for item in described),
    }
    for item in described[:12]:
        print(
            json.dumps(
                {
                    "label": item["label"],
                    "purity": round(item["purity"], 2),
                    "size": item["size"],
                    "words": item["words"][:10],
                }
            )
        )
    write_json(args.output, report)
    print(f"Wrote {args.output}")


if __name__ == "__main__":
    main()