acore-utils 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- acore/__init__.py +7 -0
- acore/cldf_utils.md +98 -0
- acore/cldf_utils.py +345 -0
- acore/cldf_utils.pyi +71 -0
- acore/ipa.py +112 -0
- acore/pi/__init__.py +34 -0
- acore/pi/apply.py +217 -0
- acore/pi/initialize.py +428 -0
- acore/py.typed +0 -0
- acore/rho/__init__.py +44 -0
- acore/rho/apply.py +395 -0
- acore/rho/initialize.py +646 -0
- acore/with_datasets.py +43 -0
- acore_utils-0.1.2.dist-info/METADATA +202 -0
- acore_utils-0.1.2.dist-info/RECORD +17 -0
- acore_utils-0.1.2.dist-info/WHEEL +4 -0
- acore_utils-0.1.2.dist-info/licenses/LICENSE +21 -0
acore/__init__.py
ADDED
acore/cldf_utils.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# cldf_utils
|
|
2
|
+
|
|
3
|
+
Functions that take a CLDF comparative dictionary from raw data to predicted protoforms, and print each step as a table.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import cldf_utils as utils
|
|
7
|
+
|
|
8
|
+
ta = utils.load_cldf("../../cldf-data/comparative-tangkhulic-cldf") # 0. read the data
|
|
9
|
+
cogs = utils.cognates(ta, proto="17", langs=["4", "1", "2", "3"]) # 1. pick cognate sets
|
|
10
|
+
aligned = utils.align_cognates(cogs) # 2. line up the sounds
|
|
11
|
+
rules = utils.correspond(aligned) # 3. find rules
|
|
12
|
+
predicted = utils.predict(rules, aligned) # 4. reconstruct
|
|
13
|
+
|
|
14
|
+
utils.print_cognates(cogs[:10]) # show any step
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Every step returns a list in the same order: `aligned[i]` and `predicted[i]` belong to `cogs[i]`.
|
|
18
|
+
|
|
19
|
+
Languages are identified by their **CLDF ID**, the `ID` column of `languages.csv`. These are numbers in Austronesian and Tangkhulic (`"4"` is Ukhrul), and names in Uralic (`"Finnish"`) and Chinese (`"Cantonese"`). `ta["languages"]` lists each ID with its name.
|
|
20
|
+
|
|
21
|
+
Words are split into sounds by [PanPhon](https://github.com/dmort27/panphon)'s `ipa_segs`, and sets are aligned by [emalign](https://github.com/changelinglab/emalign). Both are in the project's `cldf-utils` dependency group, so from the repository root:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync --group cldf-utils # or: pip install panphon "emalign @ git+https://github.com/changelinglab/emalign.git"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
PanPhon keeps only symbols it knows. For example, it drops the tone numbers of Middle Chinese protoforms (`kʰwen²` becomes `kʰwen`), but keeps tone letters such as `˥˩`. It also keeps sequences like `ts` as two sounds unless a tie bar joins them (`t͡s`).
|
|
28
|
+
|
|
29
|
+
## Results
|
|
30
|
+
|
|
31
|
+
Cognates and alignments have three parts, `(protoform, reflexes, auxiliary)`, and you only need the first two:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
Cognate(protoform='ka', reflexes={'4': 'ka', '1': 'ku', '2': 'ke', '3': 'ki'}, auxiliary={...})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`auxiliary` carries extra information from the dataset that the print functions use, such as the gloss, the cognate set's ID, the proto-language and the language names.
|
|
38
|
+
|
|
39
|
+
## Data functions
|
|
40
|
+
|
|
41
|
+
**0. `load_cldf(path)`** reads a CLDF dataset folder.
|
|
42
|
+
|
|
43
|
+
**1. `cognates(data, proto=None, langs=None)`** gives one `Cognate` per cognate set:
|
|
44
|
+
- `protoform` is the reconstruction in IPA, and `reflexes` holds each daughter language's word in IPA, e.g. `{'4': 'ka', '1': 'ku'}`.
|
|
45
|
+
- `proto` keeps only the sets of that proto-language, and `langs` picks the daughter languages and their order. By default you get every set and every daughter language.
|
|
46
|
+
- Each language gets one word per set (the first listed), and only the part of the word that is cognate. Parts are separated by `+` or spaces.
|
|
47
|
+
|
|
48
|
+
**2. `align_cognates(cogs)`** lines up the protoform and the words sound by sound, using `-` for gaps. emalign learns how similar sounds are from all the sets at once, so the sets are aligned together. Columns where no word has a sound are removed. All the lists of one set have the same length, e.g. `protoform=['k', 'a'], reflexes={'4': ['k', 'a'], '1': ['k', 'u']}`.
|
|
49
|
+
|
|
50
|
+
**3. `correspond(aligned, window=0)`** turns the alignments into rules (see below). Each distinct pattern of daughter sounds becomes one rule, concluding the proto sound it most often lines up with, and the most frequent rules come first. With `window=1`, the pattern also includes the sound before and after, which separates more cases.
|
|
51
|
+
|
|
52
|
+
**4. `predict(rules, aligned)`** rebuilds each protoform from the daughters' aligned sounds, column by column. It returns a `Cognate` per set with the predicted protoform, to compare with `cogs[i].protoform`.
|
|
53
|
+
|
|
54
|
+
## Rules
|
|
55
|
+
|
|
56
|
+
Rules use the same format as `pi_logic.py`: `(conditions, conclusion)`, read as "if every condition holds, the proto sound is the conclusion". A condition maps a key, `Key(language ID, offset)`, to a sound. A `Key` is a named tuple equal to the plain tuple `("4", 0)`, so either can be written:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
rule = ({("4", 0): "r", ("1", 0): "ð"}, "ð") # Ukhrul r and Kachai ð come from *ð
|
|
60
|
+
rule = ({("4", 0): "k", ("4", -1): "^"}, "k") # Ukhrul k at the start of a word
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `("4", 0)` is language 4's sound in the current column. `("4", -1)` is the sound before it, and `("4", 1)` the sound after.
|
|
64
|
+
- `"^"` means before the start of the word, `"$"` after its end, and `"-"` a gap.
|
|
65
|
+
- A rule applies when all its conditions hold. If several rules apply, the one with the most conditions wins, and ties go to the earlier rule.
|
|
66
|
+
- A conclusion of `""` or `"-"` adds nothing, and so does a column that no rule matches.
|
|
67
|
+
|
|
68
|
+
`correspond` returns `Rule(conditions, conclusion, auxiliary)`, whose condition keys are `Key`s, equal to the tuples of hand-written rules, so the two mix freely (e.g. `predict(my_rules + rules, aligned)`). Its `auxiliary` holds how often the rule's pattern occurs, how often each other proto sound occurs with the same pattern (these are the cases it can't separate), and the proto sounds before and after it if they are the same every time. Because rules are `(conditions, conclusion)` pairs, they work directly with `pi_logic`, e.g. `ImplicationSystem.from_tuples(rule[:2] for rule in rules)`.
|
|
69
|
+
|
|
70
|
+
## Evaluation functions
|
|
71
|
+
|
|
72
|
+
**`evaluate(rules, aligned, weight=2.0)`** scores a rule set the same way as `iterative_update_pi.py`: **L = 2E + wR**.
|
|
73
|
+
- **E** is the number of cognate sets whose predicted protoform differs from the attested one.
|
|
74
|
+
- **R** is the number of rules, and **w** is `weight`.
|
|
75
|
+
|
|
76
|
+
It returns `Loss(total, errors, rules)`, e.g. `Loss(total=1242.0, errors=73, rules=548)`. A lower total is better, so compare two rule sets with `evaluate(a, aligned).total < evaluate(b, aligned).total`.
|
|
77
|
+
|
|
78
|
+
**`metrics(predicted)`** measures how close the predictions from `predict` are to the attested protoforms. It returns `Metrics(accuracy, per, pfer)`, e.g. `Metrics(accuracy=0.94, per=0.021, pfer=0.010)`:
|
|
79
|
+
- **accuracy** is the share of protoforms predicted exactly.
|
|
80
|
+
- **per** (phoneme error rate) is the number of sounds to insert, delete or substitute to turn the predictions into the attested protoforms, per attested sound.
|
|
81
|
+
- **pfer** (phonetic feature error rate) is the same, but a substitution costs only the share of PanPhon features that differ, so near misses like *t* for *d* count less.
|
|
82
|
+
|
|
83
|
+
## Display functions
|
|
84
|
+
|
|
85
|
+
Each takes the output of one data function:
|
|
86
|
+
|
|
87
|
+
- **`print_cognates(cogs)`**: a row per set, with its protoform (in a column titled with the proto-language's name), gloss, and each language's word.
|
|
88
|
+
- **`print_aligned(aligned[i])`**: one aligned set, with a row per language (name, ID) and a column per sound.
|
|
89
|
+
- **`print_rules(rules)`**: a row per rule, written as a sound change, `*k → k ʔ ∅ / _#`. It reads "proto *k becomes k, ʔ and nothing at the end of a word", and the count is at the end. A condition at an offset gets a column like `Ukhrul -1`.
|
|
90
|
+
- **`print_predicted(predicted)`**: a row per set, with its gloss, the attested protoform (under the proto-language's name) and the predicted one.
|
|
91
|
+
- **`print_loss(loss)`**: the loss and its parts (errors, rules, weight). Pass a list of losses to compare several rule sets, one row each.
|
|
92
|
+
- **`print_metrics(scores)`**: accuracy, PER and PFER, also for one `Metrics` or a list of them.
|
|
93
|
+
|
|
94
|
+
In the tables, the first row has the language names and the second row their IDs. The data starts on the third row, numbered from 0, so row `i` is `cogs[i]`. A list that mixes proto-languages gets the title "Protoform" instead of a name. A table wider than the terminal is split into blocks that fit, each repeating the first columns (row number, protoform, gloss), so every language stays readable however many there are. To show only some languages, pick them in `cognates(data, langs=[...])`.
|
|
95
|
+
|
|
96
|
+
## Running
|
|
97
|
+
|
|
98
|
+
From `cldf-utils`, run `python3 -i with_datasets.py` to open Python with the four datasets loaded as `au` (Austronesian), `ur` (Uralic), `ch` (Chinese) and `ta` (Tangkhulic). The file's comments list commands to try.
|
acore/cldf_utils.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Comparative-method utilities for CLDF comparative dictionaries (guide: cldf_utils.md)."""
|
|
2
|
+
import io, re, shutil, unicodedata
|
|
3
|
+
from collections import Counter, defaultdict, namedtuple
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import emalign.cldf_io
|
|
8
|
+
import panphon.distance
|
|
9
|
+
|
|
10
|
+
from acore.ipa import GAP, START, END, BOUNDARY, NULL, segment as _segment
|
|
11
|
+
|
|
12
|
+
ARROW = "→"
|
|
13
|
+
|
|
14
|
+
Cognate = namedtuple("Cognate", "protoform reflexes auxiliary", defaults=[{}])
|
|
15
|
+
Alignment = namedtuple("Alignment", "protoform reflexes auxiliary", defaults=[{}])
|
|
16
|
+
Rule = namedtuple("Rule", "conditions conclusion auxiliary", defaults=[{}])
|
|
17
|
+
Key = namedtuple("Key", "lang offset", defaults=[0])
|
|
18
|
+
Loss = namedtuple("Loss", "total errors rules")
|
|
19
|
+
Metrics = namedtuple("Metrics", "accuracy per pfer")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Helpers for the data functions ---------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _protoform(name: str) -> str:
|
|
26
|
+
return "".join(_segment(re.split(r"[(,~/]", name, maxsplit=1)[0]))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _morph(form: str, index: str) -> str:
|
|
30
|
+
morphs, i = [m for m in re.split(r"[+ ]", form) if m], int(index) if index.isdigit() else 0
|
|
31
|
+
return morphs[i] if i < len(morphs) else ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _join(segs) -> str:
|
|
35
|
+
return "".join(seg for seg in segs if seg != GAP)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _gapless(rows) -> list[int]:
|
|
39
|
+
return [i for i, column in enumerate(zip(*rows)) if set(column) != {GAP}]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _daughters(a) -> tuple[list[int], dict[str, list[str]]]:
|
|
43
|
+
keep = _gapless(a.reflexes.values())
|
|
44
|
+
return keep, {lang: [row[i] for i in keep] for lang, row in a.reflexes.items()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _context(rows: dict, i: int, window: int) -> dict[Key, str]:
|
|
48
|
+
return {Key(lang, offset): row[i + offset] if 0 <= i + offset < len(row)
|
|
49
|
+
else START if i + offset < 0 else END
|
|
50
|
+
for lang, row in rows.items() for offset in range(-window, window + 1)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _conditions(conditions) -> dict[Key, str]:
|
|
54
|
+
return {Key(str(lang), int(offset)): unicodedata.normalize("NFC", seg)
|
|
55
|
+
for (lang, offset), seg in conditions.items()}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Data functions: CLDF folder -> cognates -> alignments -> rules -> predictions -----------------
|
|
59
|
+
|
|
60
|
+
def load_cldf(path: str | Path) -> dict[str, pd.DataFrame]:
|
|
61
|
+
"""Read a CLDF folder into one DataFrame per table (languages, forms, cognates, ...)."""
|
|
62
|
+
folder = Path(path) / "cldf" if (Path(path) / "cldf").is_dir() else Path(path)
|
|
63
|
+
data = {}
|
|
64
|
+
for name in ("languages", "forms", "cognates", "cognatesets", "etyma", "parameters"):
|
|
65
|
+
if (file := folder / f"{name}.csv").exists():
|
|
66
|
+
text = file.read_bytes().decode("utf-8-sig").replace("\r", "")
|
|
67
|
+
data[name] = pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cognates(data, proto=None, langs=None) -> list[Cognate]:
|
|
72
|
+
"""Cognate sets of proto-language ``proto`` (default: all), with the words of ``langs``
|
|
73
|
+
(default: every daughter language), in that order. Languages are given by CLDF ID."""
|
|
74
|
+
languages = data["languages"]
|
|
75
|
+
if langs is None:
|
|
76
|
+
langs = languages.ID[languages.Is_Proto.str.lower() != "true"]
|
|
77
|
+
rank = {str(lang): r for r, lang in enumerate(langs)}
|
|
78
|
+
etyma = data["etyma"].set_index("ID")
|
|
79
|
+
sets = data["cognatesets"].set_index("ID").Etymon_ID
|
|
80
|
+
sets = sets[sets.isin(etyma.index)]
|
|
81
|
+
protos = sets.map(etyma.Language_ID)
|
|
82
|
+
if proto is not None:
|
|
83
|
+
sets = sets[protos == str(proto)]
|
|
84
|
+
protoforms = sets.map(etyma.Name).map(_protoform)
|
|
85
|
+
forms = data["forms"][data["forms"].Language_ID.isin(rank)]
|
|
86
|
+
links = data["cognates"][data["cognates"].Cognateset_ID.isin(sets.index)].merge(
|
|
87
|
+
forms[["ID", "Language_ID", "Local_ID", "Form"]], left_on="Form_ID", right_on="ID")
|
|
88
|
+
links = links.assign(word=[_morph(f, i) for f, i in zip(links.Form, links.Morph_Index)],
|
|
89
|
+
rank=links.Language_ID.map(rank))
|
|
90
|
+
links = links[links.word.map(_segment).astype(bool)].sort_values(
|
|
91
|
+
"Local_ID", key=lambda ids: pd.to_numeric(ids, errors="coerce"), kind="stable")
|
|
92
|
+
links = links.drop_duplicates(["Cognateset_ID", "Language_ID"])
|
|
93
|
+
links = links.sort_values("rank", kind="stable")
|
|
94
|
+
reflexes = defaultdict(dict)
|
|
95
|
+
for set_id, lang, word in zip(links.Cognateset_ID, links.Language_ID, links.word):
|
|
96
|
+
reflexes[set_id][lang] = word
|
|
97
|
+
names = dict(zip(languages.ID, languages.Name))
|
|
98
|
+
glosses = dict(zip(data["cognatesets"].ID, data["cognatesets"].Description))
|
|
99
|
+
protos, cogs = protos.to_dict(), []
|
|
100
|
+
for set_id, form in protoforms.items():
|
|
101
|
+
if form and set_id in reflexes:
|
|
102
|
+
ids = (protos[set_id], *reflexes[set_id])
|
|
103
|
+
aux = {"id": set_id, "proto": protos[set_id], "gloss": glosses.get(set_id, ""),
|
|
104
|
+
"names": {lang: names.get(lang, lang) for lang in ids}}
|
|
105
|
+
cogs.append(Cognate(form, reflexes[set_id], aux))
|
|
106
|
+
return cogs
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def align_cognates(cogs: list[Cognate]) -> list[Alignment]:
|
|
110
|
+
"""Align each set's words and protoform segment by segment ('-' = gap) with emalign, fitted
|
|
111
|
+
on all the sets, so that all the lists of one set have the same length."""
|
|
112
|
+
sets, rows = [], {}
|
|
113
|
+
for n, cog in enumerate(cogs):
|
|
114
|
+
words = [" ".join(_segment(word)) for word in (*cog.reflexes.values(), cog.protoform)]
|
|
115
|
+
forms = [(f"{n}.{k}", word) for k, word in enumerate(words)]
|
|
116
|
+
rows.update((i, word.split()) for i, word in forms)
|
|
117
|
+
entries = [(emalign.cldf_io.CognateEntry(i, i, str(n), 0),
|
|
118
|
+
emalign.cldf_io.Form(i, i, word, [word])) for i, word in forms]
|
|
119
|
+
sets.append(emalign.cldf_io.CognateSet(str(n), entries))
|
|
120
|
+
rows.update((r.form_id, unicodedata.normalize("NFC", r.aligned_form).split("|"))
|
|
121
|
+
for r in emalign.CognateAligner(random_seed=0).fit_and_align(sets))
|
|
122
|
+
aligned = []
|
|
123
|
+
for n, cog in enumerate(cogs):
|
|
124
|
+
table = [rows[f"{n}.{k}"] for k in range(len(cog.reflexes) + 1)]
|
|
125
|
+
keep = _gapless(table)
|
|
126
|
+
table = [[row[i] for i in keep] for row in table]
|
|
127
|
+
aligned.append(Alignment(table[-1], dict(zip(cog.reflexes, table)), cog.auxiliary))
|
|
128
|
+
return aligned
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def correspond(aligned: list[Alignment], window: int = 0) -> list[Rule]:
|
|
132
|
+
"""One rule per distinct context (the daughters' segments within ``window`` columns of a
|
|
133
|
+
column), concluding the proto segment most often aligned with it; most frequent first. The
|
|
134
|
+
auxiliary data holds its count, every conclusion's count, and the proto environment
|
|
135
|
+
(before, after) shared by all its occurrences ('' where they differ)."""
|
|
136
|
+
counts, first, envs = defaultdict(Counter), {}, defaultdict(lambda: (set(), set()))
|
|
137
|
+
for a in aligned:
|
|
138
|
+
keep, rows = _daughters(a)
|
|
139
|
+
sounds = [i for i, seg in enumerate(a.protoform) if seg != GAP]
|
|
140
|
+
for n, i in enumerate(keep):
|
|
141
|
+
context, proto = tuple(_context(rows, n, window).items()), a.protoform[i]
|
|
142
|
+
counts[context][proto] += 1
|
|
143
|
+
first.setdefault(context, a.auxiliary)
|
|
144
|
+
before, after = envs[context, proto]
|
|
145
|
+
before.add(next((a.protoform[j] for j in reversed(sounds) if j < i), BOUNDARY))
|
|
146
|
+
after.add(next((a.protoform[j] for j in sounds if j > i), BOUNDARY))
|
|
147
|
+
rules = []
|
|
148
|
+
for context, protos in counts.items():
|
|
149
|
+
proto, count = protos.most_common(1)[0]
|
|
150
|
+
env = tuple(next(iter(side)) if len(side) == 1 else "" for side in envs[context, proto])
|
|
151
|
+
aux = {"count": count, "protos": dict(protos.most_common()), "env": env,
|
|
152
|
+
"proto": first[context].get("proto", ""), "names": first[context].get("names", {})}
|
|
153
|
+
rules.append(Rule(dict(context), proto, aux))
|
|
154
|
+
return sorted(rules, key=lambda rule: -rule.auxiliary["count"])
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def predict(rules, aligned: list[Alignment]) -> list[Cognate]:
|
|
158
|
+
"""Predict each set's protoform from its daughters' aligned segments. In each column, the
|
|
159
|
+
matching rule with the most conditions (the earlier one on ties) adds its conclusion."""
|
|
160
|
+
rules = [(_conditions(rule[0]), unicodedata.normalize("NFC", rule[1])) for rule in rules]
|
|
161
|
+
rules.sort(key=lambda r: -len(r[0]))
|
|
162
|
+
reach = max((abs(key.offset) for conds, _ in rules for key in conds), default=0)
|
|
163
|
+
frequency = Counter(item for conds, _ in rules for item in conds.items())
|
|
164
|
+
index = defaultdict(list)
|
|
165
|
+
for n, (conds, _) in enumerate(rules):
|
|
166
|
+
index[min(conds.items(), key=frequency.__getitem__, default=None)].append(n)
|
|
167
|
+
predicted = []
|
|
168
|
+
for a in aligned:
|
|
169
|
+
keep, rows = _daughters(a)
|
|
170
|
+
proto = []
|
|
171
|
+
for i in range(len(keep)):
|
|
172
|
+
context = _context(rows, i, reach)
|
|
173
|
+
hits = {*index[None], *(n for item in context.items() for n in index.get(item, ()))}
|
|
174
|
+
matches = (rules[n][1] for n in sorted(hits)
|
|
175
|
+
if all(context.get(key) == seg for key, seg in rules[n][0].items()))
|
|
176
|
+
proto.append(next(matches, ""))
|
|
177
|
+
words = {lang: _join(row) for lang, row in rows.items()}
|
|
178
|
+
aux = {**a.auxiliary, "reference": _join(a.protoform)}
|
|
179
|
+
predicted.append(Cognate(_join(proto), words, aux))
|
|
180
|
+
return predicted
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Evaluation functions: the loss of iterative_update_pi, and accuracy and error rates -----------
|
|
184
|
+
|
|
185
|
+
def evaluate(rules, aligned: list[Alignment], weight: float = 2.0) -> Loss:
|
|
186
|
+
"""Loss of ``rules`` on ``aligned``: L = 2E + wR, where E counts the sets whose predicted
|
|
187
|
+
protoform differs from the attested one, R counts the rules and w is ``weight``."""
|
|
188
|
+
rules = list(rules)
|
|
189
|
+
errors = sum(cog.protoform != cog.auxiliary["reference"] for cog in predict(rules, aligned))
|
|
190
|
+
return Loss(2 * errors + weight * len(rules), errors, len(rules))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@lru_cache(maxsize=None)
|
|
194
|
+
def _distance():
|
|
195
|
+
return panphon.distance.Distance()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def metrics(predicted: list[Cognate]) -> Metrics:
|
|
199
|
+
"""Accuracy (share of protoforms predicted exactly), phoneme error rate (segment edit
|
|
200
|
+
distance) and PanPhon feature error rate (feature edit distance), both per attested segment."""
|
|
201
|
+
pairs = [(cog.protoform, cog.auxiliary["reference"]) for cog in predicted]
|
|
202
|
+
d, one, differ = _distance(), lambda _: 1, lambda a, b: a != b
|
|
203
|
+
edits = sum(d.min_edit_distance(one, one, differ, [""], list(_segment(p)), list(_segment(r)))
|
|
204
|
+
for p, r in pairs)
|
|
205
|
+
features = sum(d.feature_edit_distance(p, r) for p, r in pairs)
|
|
206
|
+
segments = max(sum(len(_segment(r)) for _, r in pairs), 1)
|
|
207
|
+
exact = sum(p == r for p, r in pairs) / max(len(pairs), 1)
|
|
208
|
+
return Metrics(exact, edits / segments, features / segments)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Display functions: print the data functions' outputs as plain, aligned tables ------------------
|
|
212
|
+
|
|
213
|
+
def _aux(item) -> dict:
|
|
214
|
+
return item[2] if len(item) > 2 else {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _names(items) -> dict[str, str]:
|
|
218
|
+
return {lang: name for item in items for lang, name in _aux(item).get("names", {}).items()}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _proto_name(aux: dict) -> str:
|
|
222
|
+
return aux.get("names", {}).get(aux.get("proto"), aux.get("proto", ""))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _star(form: str) -> str:
|
|
226
|
+
return f"*{form}" if form else ""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _null(seg: str) -> str:
|
|
230
|
+
return NULL if seg in (GAP, "") else seg
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _clip(text: str, limit: int = 24) -> str:
|
|
234
|
+
return text if len(text) <= limit else text[:limit - 1].rstrip() + "…"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _width(text: str) -> int:
|
|
238
|
+
return sum(0 if unicodedata.category(char) in ("Mn", "Me", "Cf")
|
|
239
|
+
else 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1 for char in text)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _order(dicts) -> list:
|
|
243
|
+
order = []
|
|
244
|
+
for keys in map(list, dicts):
|
|
245
|
+
for i, key in enumerate(keys):
|
|
246
|
+
if key not in order:
|
|
247
|
+
later = [order.index(k) for k in keys[i + 1:] if k in order]
|
|
248
|
+
order.insert(min(later, default=len(order)), key)
|
|
249
|
+
return order
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _proto(items, default: str) -> tuple[str, str]:
|
|
253
|
+
protos = {(_proto_name(aux), aux["proto"]) for aux in map(_aux, items) if aux.get("proto")}
|
|
254
|
+
return protos.pop() if len(protos) == 1 else (default, "")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _header(*cells: tuple[str, str]) -> list[list[str]]:
|
|
258
|
+
return [[title for title, _ in cells], [code for _, code in cells]]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _print_table(rows: list[list[str]], frozen: int = 1) -> None:
|
|
262
|
+
widths = [max(map(_width, column)) for column in zip(*rows)]
|
|
263
|
+
room = shutil.get_terminal_size().columns - sum(widths[:frozen]) - 2 * frozen
|
|
264
|
+
used, blocks = 0, [[]]
|
|
265
|
+
for c in range(frozen, len(widths)):
|
|
266
|
+
if blocks[-1] and used + widths[c] > room:
|
|
267
|
+
blocks.append([])
|
|
268
|
+
used = 0
|
|
269
|
+
blocks[-1].append(c)
|
|
270
|
+
used += widths[c] + 2
|
|
271
|
+
for n, block in enumerate(blocks):
|
|
272
|
+
if n:
|
|
273
|
+
print()
|
|
274
|
+
for row in rows:
|
|
275
|
+
print(" ".join(row[c] + " " * (widths[c] - _width(row[c]))
|
|
276
|
+
for c in (*range(frozen), *block)).rstrip())
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def print_cognates(cogs: list[Cognate]) -> None:
|
|
280
|
+
"""Print cognate sets as a table: protoform (titled by its proto-language), gloss, then each
|
|
281
|
+
language's word. The second row holds the language IDs."""
|
|
282
|
+
langs, names = _order(cog.reflexes for cog in cogs), _names(cogs)
|
|
283
|
+
rows = _header(("", ""), _proto(cogs, "Protoform"), ("Gloss", ""),
|
|
284
|
+
*((names.get(lang, lang), lang) for lang in langs))
|
|
285
|
+
for n, cog in enumerate(cogs):
|
|
286
|
+
rows.append([str(n), _star(cog.protoform), _clip(_aux(cog).get("gloss", "")),
|
|
287
|
+
*(cog.reflexes.get(lang, "") for lang in langs)])
|
|
288
|
+
_print_table(rows, frozen=3)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def print_aligned(a: Alignment) -> None:
|
|
292
|
+
"""Print one aligned cognate set: a row per language with its name and ID, and a column per
|
|
293
|
+
segment."""
|
|
294
|
+
aux = _aux(a)
|
|
295
|
+
rows = [["", "", *map(str, range(len(a.protoform)))],
|
|
296
|
+
[_proto_name(aux) or "Protoform", aux.get("proto", ""), *a.protoform]]
|
|
297
|
+
rows += [[aux.get("names", {}).get(lang, lang), lang, *row] for lang, row in a.reflexes.items()]
|
|
298
|
+
print(aux.get("gloss", ""))
|
|
299
|
+
_print_table(rows, frozen=2)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def print_rules(rules: list[Rule]) -> None:
|
|
303
|
+
"""Print rules as sound changes, *conclusion → each condition's segment / environment, with
|
|
304
|
+
how often each occurs. A condition with an offset is titled e.g. Ukhrul -1."""
|
|
305
|
+
conds = [_conditions(rule[0]) for rule in rules]
|
|
306
|
+
keys, names = _order(conds), _names(rules)
|
|
307
|
+
rows = _header(("", ""), _proto(rules, "Protoform"), ("", ""),
|
|
308
|
+
*((names.get(lang, lang) + (f" {offset:+d}" if offset else ""), lang)
|
|
309
|
+
for lang, offset in keys), ("", ""), ("Count", ""))
|
|
310
|
+
for n, (rule, cond) in enumerate(zip(rules, conds)):
|
|
311
|
+
aux = _aux(rule)
|
|
312
|
+
before, after = aux.get("env", ("", ""))
|
|
313
|
+
segs = (_null(cond[key]) if key in cond else "" for key in keys)
|
|
314
|
+
rows.append([str(n), NULL if rule[1] in (GAP, "") else _star(rule[1]), ARROW, *segs,
|
|
315
|
+
f"/ {before}_{after}" if before or after else "", str(aux.get("count", ""))])
|
|
316
|
+
_print_table(rows, frozen=3)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def print_predicted(predicted: list[Cognate]) -> None:
|
|
320
|
+
"""Print each set's gloss, attested protoform (titled by its proto-language) and predicted
|
|
321
|
+
protoform. The second row holds the proto-language ID."""
|
|
322
|
+
rows = _header(("", ""), ("Gloss", ""), _proto(predicted, "Reference"), ("Predicted", ""))
|
|
323
|
+
for n, cog in enumerate(predicted):
|
|
324
|
+
aux = _aux(cog)
|
|
325
|
+
rows.append([str(n), _clip(aux.get("gloss", "")), _star(aux.get("reference", "")),
|
|
326
|
+
_star(cog.protoform)])
|
|
327
|
+
_print_table(rows)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def print_loss(loss) -> None:
|
|
331
|
+
"""Print a Loss, or a list of them (e.g. one per rule set), as L = 2E + wR and its parts."""
|
|
332
|
+
rows = [["", "Loss", "Errors", "Rules", "Weight"]]
|
|
333
|
+
for n, (total, errors, rules) in enumerate([loss] if isinstance(loss, Loss) else loss):
|
|
334
|
+
weight = f"{(total - 2 * errors) / rules:.10g}" if rules else ""
|
|
335
|
+
rows.append([str(n), f"{total:.10g}", str(errors), str(rules), weight])
|
|
336
|
+
print("L = 2E + wR")
|
|
337
|
+
_print_table(rows)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def print_metrics(scores) -> None:
|
|
341
|
+
"""Print a Metrics, or a list of them (e.g. one per rule set): accuracy, PER and PFER."""
|
|
342
|
+
rows = [["", "Accuracy", "PER", "PFER"]]
|
|
343
|
+
rows += [[str(n), *(f"{value:.3f}" for value in values)]
|
|
344
|
+
for n, values in enumerate([scores] if isinstance(scores, Metrics) else scores)]
|
|
345
|
+
_print_table(rows)
|
acore/cldf_utils.pyi
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Interface of cldf_utils.py. Guide: cldf_utils.md.
|
|
2
|
+
# Languages are identified by their CLDF ID, the ID column of languages.csv ('19094', 'Finnish').
|
|
3
|
+
# IPA is segmented with PanPhon's FeatureTable.ipa_segs; alignment uses emalign.
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, NamedTuple
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
# Cognates and alignments are (protoform, reflexes, auxiliary); rules are (conditions,
|
|
12
|
+
# conclusion, auxiliary), the pi_logic format. Work with the first two fields; auxiliary carries
|
|
13
|
+
# extra CLDF information for the display functions (default {}):
|
|
14
|
+
# Cognate, Alignment: {'id': cognate set ID, 'proto': proto-language ID, 'gloss': str,
|
|
15
|
+
# 'names': {language ID: name}}, plus 'reference' from predict
|
|
16
|
+
# Rule: {'count': int, 'protos': {conclusion: count}, 'env': (before, after), 'proto', 'names'}
|
|
17
|
+
Aux = dict[str, Any]
|
|
18
|
+
Cognate = NamedTuple("Cognate", [("protoform", str), ("reflexes", dict[str, str]),
|
|
19
|
+
("auxiliary", Aux)])
|
|
20
|
+
Alignment = NamedTuple("Alignment", [("protoform", list[str]),
|
|
21
|
+
("reflexes", dict[str, list[str]]), ("auxiliary", Aux)])
|
|
22
|
+
|
|
23
|
+
# A condition key is Key(language ID, offset), equal to the tuple ('4', 0): that language's segment
|
|
24
|
+
# at an offset from the current column. Past the edges of a word it is '^' (start) or '$' (end).
|
|
25
|
+
Key = NamedTuple("Key", [("lang", str), ("offset", int)])
|
|
26
|
+
Rule = NamedTuple("Rule", [("conditions", dict[Key, str]), ("conclusion", str),
|
|
27
|
+
("auxiliary", Aux)])
|
|
28
|
+
|
|
29
|
+
# Any (conditions, conclusion) pair is a rule, e.g. ({('4', 0): 'r', ('1', -1): '^'}, 'ð').
|
|
30
|
+
Rules = Iterable[Rule | tuple[Mapping[tuple[str, int], str], str]]
|
|
31
|
+
|
|
32
|
+
# Data functions
|
|
33
|
+
|
|
34
|
+
def load_cldf(path: str | Path) -> dict[str, pd.DataFrame]: ...
|
|
35
|
+
|
|
36
|
+
def cognates(
|
|
37
|
+
data: dict[str, pd.DataFrame], proto: str | None = ..., langs: Iterable[str] | None = ...
|
|
38
|
+
) -> list[Cognate]: ...
|
|
39
|
+
|
|
40
|
+
def align_cognates(cogs: list[Cognate]) -> list[Alignment]: ... # equal-length lists per set
|
|
41
|
+
|
|
42
|
+
def correspond(aligned: list[Alignment], window: int = ...) -> list[Rule]: ... # most frequent
|
|
43
|
+
|
|
44
|
+
def predict(rules: Rules, aligned: list[Alignment]) -> list[Cognate]: ...
|
|
45
|
+
|
|
46
|
+
# Evaluation functions
|
|
47
|
+
|
|
48
|
+
# L = 2E + wR: E = sets whose predicted protoform differs from the attested one, R = rules.
|
|
49
|
+
Loss = NamedTuple("Loss", [("total", float), ("errors", int), ("rules", int)])
|
|
50
|
+
|
|
51
|
+
def evaluate(rules: Rules, aligned: list[Alignment], weight: float = ...) -> Loss: ...
|
|
52
|
+
|
|
53
|
+
# accuracy: share of exact protoforms; per, pfer: segment and PanPhon feature edit distance per
|
|
54
|
+
# attested segment.
|
|
55
|
+
Metrics = NamedTuple("Metrics", [("accuracy", float), ("per", float), ("pfer", float)])
|
|
56
|
+
|
|
57
|
+
def metrics(predicted: list[Cognate]) -> Metrics: ...
|
|
58
|
+
|
|
59
|
+
# Display functions
|
|
60
|
+
|
|
61
|
+
def print_cognates(cogs: list[Cognate]) -> None: ...
|
|
62
|
+
|
|
63
|
+
def print_aligned(a: Alignment) -> None: ... # one set
|
|
64
|
+
|
|
65
|
+
def print_rules(rules: Rules) -> None: ...
|
|
66
|
+
|
|
67
|
+
def print_predicted(predicted: list[Cognate]) -> None: ...
|
|
68
|
+
|
|
69
|
+
def print_loss(loss: Loss | list[Loss]) -> None: ...
|
|
70
|
+
|
|
71
|
+
def print_metrics(scores: Metrics | list[Metrics]) -> None: ...
|
acore/ipa.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""IPA segmentation and normalization utilities using panphon.
|
|
2
|
+
|
|
3
|
+
This module provides consistent IPA tokenization across the acore library
|
|
4
|
+
using panphon's FeatureTable.ipa_segs() method.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import unicodedata
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import panphon
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
# Shared FeatureTable instance
|
|
19
|
+
_FT = panphon.FeatureTable()
|
|
20
|
+
|
|
21
|
+
# Common symbols
|
|
22
|
+
GAP = "-"
|
|
23
|
+
START = "^"
|
|
24
|
+
END = "$"
|
|
25
|
+
BOUNDARY = "#"
|
|
26
|
+
NULL = "∅"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=None)
|
|
30
|
+
def segment(form: str, normalize: bool = True) -> tuple[str, ...]:
|
|
31
|
+
"""Segment an IPA string into phonemes using panphon.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
form: IPA string to segment.
|
|
35
|
+
normalize: If True, normalize each segment to NFC.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Tuple of IPA segments (phonemes).
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> segment("kʰat")
|
|
42
|
+
('kʰ', 'a', 't')
|
|
43
|
+
"""
|
|
44
|
+
# Replace ASCII 'g' with IPA 'ɡ' (U+0261)
|
|
45
|
+
form = form.replace("g", "ɡ")
|
|
46
|
+
segments = _FT.ipa_segs(form)
|
|
47
|
+
if normalize:
|
|
48
|
+
return tuple(unicodedata.normalize("NFC", seg) for seg in segments)
|
|
49
|
+
return tuple(segments)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def join(segments: Sequence[str], exclude_gaps: bool = True) -> str:
|
|
53
|
+
"""Join segments into a string.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
segments: Sequence of IPA segments.
|
|
57
|
+
exclude_gaps: If True, exclude GAP symbols from output.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Joined string.
|
|
61
|
+
"""
|
|
62
|
+
if exclude_gaps:
|
|
63
|
+
return "".join(seg for seg in segments if seg != GAP)
|
|
64
|
+
return "".join(segments)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize(form: str, form_type: str = "NFC") -> str:
|
|
68
|
+
"""Normalize a Unicode string.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
form: String to normalize.
|
|
72
|
+
form_type: Unicode normalization form (NFC, NFD, NFKC, NFKD).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Normalized string.
|
|
76
|
+
"""
|
|
77
|
+
return unicodedata.normalize(form_type, form)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def segment_to_string(form: str) -> str:
|
|
81
|
+
"""Segment an IPA string and rejoin it.
|
|
82
|
+
|
|
83
|
+
This ensures proper segmentation boundaries are respected.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
form: IPA string.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Re-joined string after segmentation.
|
|
90
|
+
"""
|
|
91
|
+
return join(segment(form), exclude_gaps=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_ipa(char: str) -> bool:
|
|
95
|
+
"""Check if a character is a valid IPA character.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
char: Single character to check.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True if the character is recognized by panphon.
|
|
102
|
+
"""
|
|
103
|
+
return _FT.fts(char) is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def feature_table() -> panphon.FeatureTable:
|
|
107
|
+
"""Return the shared panphon FeatureTable instance.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The panphon FeatureTable used for IPA operations.
|
|
111
|
+
"""
|
|
112
|
+
return _FT
|
acore/pi/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Pi submodule for proto-language reconstruction using implication systems.
|
|
2
|
+
|
|
3
|
+
This module provides tools for:
|
|
4
|
+
- Applying pi (implication systems) to cognate sets to generate protoforms
|
|
5
|
+
- Initializing pi from cognate sets and known protoforms
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from acore.pi.apply import (
|
|
9
|
+
Condition,
|
|
10
|
+
Implication,
|
|
11
|
+
apply_pi,
|
|
12
|
+
apply_pi_to_alignment,
|
|
13
|
+
load_pi,
|
|
14
|
+
reconstruct_from_cldf,
|
|
15
|
+
)
|
|
16
|
+
from acore.pi.initialize import (
|
|
17
|
+
OptimizationResult,
|
|
18
|
+
initialize_pi,
|
|
19
|
+
initialize_pi_from_cldf,
|
|
20
|
+
save_pi,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"apply_pi",
|
|
25
|
+
"apply_pi_to_alignment",
|
|
26
|
+
"Condition",
|
|
27
|
+
"Implication",
|
|
28
|
+
"initialize_pi",
|
|
29
|
+
"initialize_pi_from_cldf",
|
|
30
|
+
"load_pi",
|
|
31
|
+
"OptimizationResult",
|
|
32
|
+
"reconstruct_from_cldf",
|
|
33
|
+
"save_pi",
|
|
34
|
+
]
|