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 ADDED
@@ -0,0 +1,7 @@
1
+ """acore: Agent-based Comparative Reconstruction library."""
2
+
3
+ __version__ = "0.1.2"
4
+
5
+ from acore import ipa, pi, rho
6
+
7
+ __all__ = ["ipa", "pi", "rho"]
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
+ ]