runic-reader-2 / code /build_corpus.py
kesha-humonen's picture
Add files using upload-large-folder tool
ed46c5d verified
Raw History Blame Contribute Delete
7.64 kB
"""Build the inscription-level corpus and leakage-free splits.
Sources (all scraped by the thesis authors):
SRD 2014 export (signum, transliteration, English) -> target en
RunesDB / RuneS (transliteration, translation, image URLs) -> target en (langid-filtered)
christerhamp.se Gamla runor (Anton's clean scrape, Swedish) -> target sv
Rows are grouped into inscription clusters by (a) identical normalised transliteration,
(b) identical signum, (c) near-duplicate normalised transliteration (Indel ratio >= 90 for
strings of >= 20 chars) and (d) word-trigram containment >= 0.8 of the shorter string.
Splits are assigned per cluster, so no inscription appears in two splits.
"""
import json
import random
import re
import sys
from collections import Counter, defaultdict
import langid
import numpy as np
import pandas as pd
from rapidfuzz import fuzz, process
sys.path.insert(0, "/Users/humonen/runic_access/code")
from runic_norm import normalize # noqa: E402
P = "/Users/humonen/runic_access/sources/repos/runic_ai/parsers/"
CH = "/Users/humonen/runic_access/sources/gdrive/Christerhamp/christerhamp_images/christerhamp_gamla_runor_with_filenames.csv"
OUT = "/Users/humonen/runic_access/data/"
SEED = 13
def base_signum(s):
if not isinstance(s, str):
return None
s = re.split(r"[$§]", s)[0]
s = re.sub(r"\s+", " ", s).strip()
return s or None
def clean_translation(t):
if not isinstance(t, str):
return ""
t = re.sub(r"\s+", " ", t).strip()
return t
rows = []
srd = pd.read_csv(P + "srd_parallel.csv")
for r in srd.itertuples():
rows.append(dict(src="srd", signum=base_signum(r.signum), translit_raw=r.transliteration,
translation=clean_translation(r.english), lang="en", image_urls="", page=""))
rdb = pd.read_csv(P + "runes_parsed_parallel_1_10k.csv")
for r in rdb.itertuples():
rows.append(dict(src="rdb", signum=None, translit_raw=r.transliteration,
translation=clean_translation(r.translation), lang="en",
image_urls=r.images_urls if isinstance(r.images_urls, str) else "", page=r.url))
ch = pd.read_csv(CH)
for r in ch.itertuples():
sig = r.stone_id if isinstance(r.stone_id, str) and re.match(r"^[A-ZÅÄÖÖÜa-zåäö]{1,4}\s", r.stone_id) else None
rows.append(dict(src="ch", signum=base_signum(sig), translit_raw=r.transliteration,
translation=clean_translation(r.translation), lang="sv",
image_urls=r.image_url if isinstance(r.image_url, str) else "", page=r.page_url))
df = pd.DataFrame(rows)
df["norm"] = df.translit_raw.map(normalize)
n0 = len(df)
# translation must carry content
alpha = df.translation.map(lambda t: len(re.findall(r"[A-Za-zÀ-ÿþðÞÐǫ]", t)))
df = df[(df.norm.str.len() >= 2) & (alpha >= 3)].copy()
# RunesDB: keep English (or short name-like glosses, which langid cannot judge)
def rdb_keep(t):
words = t.split()
if len(words) <= 3:
return True
return langid.classify(t)[0] == "en"
mask_rdb = df.src.eq("rdb")
keep = df[mask_rdb].translation.map(rdb_keep)
df = pd.concat([df[~mask_rdb], df[mask_rdb][keep]])
df = df.reset_index(drop=True)
print(f"rows: raw {n0} -> kept {len(df)}", df.groupby(["src", "lang"]).size().to_dict())
# ---------- clustering ----------
parent = list(range(len(df)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(i, j):
a, b = find(i), find(j)
if a != b:
parent[b] = a
def union_groups(keys):
first = {}
for i, k in enumerate(keys):
if k is None or k == "":
continue
if k in first:
union(first[k], i)
else:
first[k] = i
df["nospace"] = df.norm.str.replace(" ", "", regex=False)
union_groups(df.nospace.tolist())
union_groups(df.signum.tolist())
uniq_ns = sorted(set(df.nospace))
idx_ns = defaultdict(list)
for i, s in enumerate(df.nospace):
idx_ns[s].append(i)
long_ = [s for s in uniq_ns if len(s) >= 8]
M = process.cdist(long_, long_, scorer=fuzz.ratio, score_cutoff=90, workers=-1, dtype=np.uint8)
ii, jj = np.nonzero(M)
near_pairs = [(long_[a], long_[b], int(M[a, b])) for a, b in zip(ii, jj) if a < b]
for a, b, _ in near_pairs:
union(idx_ns[a][0], idx_ns[b][0])
uniq = sorted(set(df.norm))
idx_of = defaultdict(list)
for i, s in enumerate(df.norm):
idx_of[s].append(i)
print("near-dup pairs (ratio>=90 on space-free strings >= 8 chars):", len(near_pairs))
def trigrams(s):
w = s.split()
return {" ".join(w[k:k + 3]) for k in range(len(w) - 2)}
tg = {s: trigrams(s) for s in uniq}
inv = defaultdict(set)
for s, g in tg.items():
for t in g:
inv[t].add(s)
contain_pairs = 0
for s, g in tg.items():
if len(g) < 3:
continue
cand = Counter()
for t in g:
if len(inv[t]) > 200: # formula trigrams shared by hundreds of stones carry no identity
continue
for o in inv[t]:
if o != s:
cand[o] += 1
for o, c in cand.items():
if len(tg[o]) >= len(g) and c / len(g) >= 0.8:
union(idx_of[s][0], idx_of[o][0])
contain_pairs += 1
print("containment pairs:", contain_pairs)
df["cluster"] = [find(i) for i in range(len(df))]
sizes = df.cluster.value_counts()
print("clusters:", len(sizes), "largest:", sizes.head(5).tolist(), "singletons:", int((sizes == 1).sum()))
# save boundary examples for manual inspection
random.seed(SEED)
border = [p for p in near_pairs if p[2] < 93]
with open(OUT + "inspect_near_dup_pairs.txt", "w") as f:
for a, b, sc in random.sample(border, min(40, len(border))):
f.write(f"{sc}\n {a}\n {b}\n")
big = sizes.index[0]
with open(OUT + "inspect_largest_cluster.txt", "w") as f:
for s in df[df.cluster == big].norm.head(60):
f.write(s + "\n")
# ---------- split ----------
has_img = df.image_urls.str.len() > 0
cl_img = df[has_img].groupby("cluster").src.apply(set)
clusters = sorted(sizes.index)
rng = random.Random(SEED)
rng.shuffle(clusters)
# cap the test/val clusters at size <= 8 so a single formulaic mega-cluster cannot dominate
eligible = [c for c in clusters if sizes[c] <= 8]
img_ch = [c for c in eligible if c in cl_img.index and "ch" in cl_img[c]]
img_rdb = [c for c in eligible if c in cl_img.index and "rdb" in cl_img[c] and "ch" not in cl_img[c]]
test = set(img_ch[:220]) | set(img_rdb[:220])
rest = [c for c in eligible if c not in test]
target_test_rows = 1300
for c in rest:
if df.cluster.isin(test).sum() >= target_test_rows:
break
test.add(c)
val = set()
for c in [c for c in eligible if c not in test]:
if df.cluster.isin(val).sum() >= 600:
break
val.add(c)
df["split"] = np.where(df.cluster.isin(test), "test", np.where(df.cluster.isin(val), "val", "train"))
print(df.groupby(["split", "lang"]).size().unstack().to_string())
print("test rows with images:", df[(df.split == "test") & has_img].groupby("src").size().to_dict())
# leakage audit: max similarity of every test norm to any train norm
tr = sorted(set(df[df.split == "train"].nospace))
te = df[df.split == "test"].nospace.tolist()
best = process.cdist(te, tr, scorer=fuzz.ratio, workers=-1, dtype=np.uint8).max(axis=1)
print("test->train max ratio: >=90:", int((best >= 90).sum()), ">=80:", int((best >= 80).sum()), "of", len(te))
df.loc[df.split == "test", "max_ratio_to_train"] = best
df["id"] = [f"{s}{i:05d}" for i, s in zip(range(len(df)), df.src)]
df.to_json(OUT + "corpus.jsonl", orient="records", lines=True, force_ascii=False)
print("wrote", OUT + "corpus.jsonl")