summaryrefslogtreecommitdiff
path: root/worldalign/natural_families.py
diff options
context:
space:
mode:
Diffstat (limited to 'worldalign/natural_families.py')
-rw-r--r--worldalign/natural_families.py264
1 files changed, 264 insertions, 0 deletions
diff --git a/worldalign/natural_families.py b/worldalign/natural_families.py
new file mode 100644
index 0000000..a13a1d9
--- /dev/null
+++ b/worldalign/natural_families.py
@@ -0,0 +1,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()