simphone 0.1.0__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.
- simphone/__init__.py +13 -0
- simphone/__main__.py +8 -0
- simphone/cli.py +174 -0
- simphone/distance.py +346 -0
- simphone/g2p.py +406 -0
- simphone/index.py +403 -0
- simphone-0.1.0.dist-info/METADATA +149 -0
- simphone-0.1.0.dist-info/RECORD +12 -0
- simphone-0.1.0.dist-info/WHEEL +5 -0
- simphone-0.1.0.dist-info/entry_points.txt +2 -0
- simphone-0.1.0.dist-info/licenses/LICENSE +21 -0
- simphone-0.1.0.dist-info/top_level.txt +1 -0
simphone/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Phonetic similarity search over a list of words or spans."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from simphone.g2p import normalize
|
|
6
|
+
from simphone.index import PhoneticIndex
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("simphone")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["PhoneticIndex", "normalize"]
|
simphone/__main__.py
ADDED
simphone/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Command line for building and searching a phonetic index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from simphone.g2p import normalize
|
|
11
|
+
from simphone.index import PhoneticIndex, read_span_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _queries(args: argparse.Namespace) -> list[str]:
|
|
15
|
+
out = list(args.query or [])
|
|
16
|
+
if args.queries:
|
|
17
|
+
out.extend(read_span_file(args.queries))
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_hits(query: str, hits: list[dict]) -> None:
|
|
22
|
+
print(query)
|
|
23
|
+
if not hits:
|
|
24
|
+
print(" (none)")
|
|
25
|
+
return
|
|
26
|
+
for h in hits:
|
|
27
|
+
dlen = h["dlen"]
|
|
28
|
+
sign = f"{dlen:+d}"
|
|
29
|
+
print(f" {h['text']}\t{h['sim']:.4f}\tn={h['n_units']}\tdlen={sign}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_norm(args: argparse.Namespace) -> int:
|
|
33
|
+
texts = list(args.text or [])
|
|
34
|
+
if args.queries:
|
|
35
|
+
texts.extend(read_span_file(args.queries))
|
|
36
|
+
if not texts:
|
|
37
|
+
print("pass --text and/or --queries", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
for line in normalize(texts, args.lang, workers=args.workers):
|
|
40
|
+
print(line)
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
45
|
+
spans = read_span_file(args.inventory)
|
|
46
|
+
if not spans:
|
|
47
|
+
print(f"no spans in {args.inventory}", file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
print(f"phonemizing {len(spans)} spans ({args.lang})", flush=True)
|
|
50
|
+
index = PhoneticIndex.build(
|
|
51
|
+
spans,
|
|
52
|
+
args.lang,
|
|
53
|
+
tone_weight=args.tone_weight,
|
|
54
|
+
device=args.device,
|
|
55
|
+
cache_dir=args.cache_dir,
|
|
56
|
+
en_locale=args.en_locale,
|
|
57
|
+
workers=args.workers,
|
|
58
|
+
)
|
|
59
|
+
index.save(args.save)
|
|
60
|
+
print(f"saved {len(index.spans)} spans -> {args.save}")
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
65
|
+
queries = _queries(args)
|
|
66
|
+
if not queries:
|
|
67
|
+
print("pass --query and/or --queries", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
if args.load:
|
|
70
|
+
index = PhoneticIndex.load(args.load)
|
|
71
|
+
else:
|
|
72
|
+
if not args.inventory or not args.lang:
|
|
73
|
+
print("pass --load, or --inventory with --lang", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
spans = read_span_file(args.inventory)
|
|
76
|
+
print(f"phonemizing {len(spans)} spans ({args.lang})", flush=True)
|
|
77
|
+
index = PhoneticIndex.build(
|
|
78
|
+
spans,
|
|
79
|
+
args.lang,
|
|
80
|
+
tone_weight=args.tone_weight,
|
|
81
|
+
device=args.device,
|
|
82
|
+
cache_dir=args.cache_dir,
|
|
83
|
+
en_locale=args.en_locale,
|
|
84
|
+
workers=args.workers,
|
|
85
|
+
)
|
|
86
|
+
hits = index.search_many(
|
|
87
|
+
queries,
|
|
88
|
+
topk=args.topk,
|
|
89
|
+
prefilter=args.prefilter,
|
|
90
|
+
sim_min=args.sim_min,
|
|
91
|
+
workers=args.workers,
|
|
92
|
+
block_size=args.block_size,
|
|
93
|
+
device=args.device,
|
|
94
|
+
cache_dir=args.cache_dir,
|
|
95
|
+
en_locale=args.en_locale,
|
|
96
|
+
)
|
|
97
|
+
rows = [{"query": q, "neighbors": h} for q, h in zip(queries, hits)]
|
|
98
|
+
for row in rows:
|
|
99
|
+
_print_hits(row["query"], row["neighbors"])
|
|
100
|
+
sys.stdout.flush()
|
|
101
|
+
if args.out:
|
|
102
|
+
path = Path(args.out)
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
105
|
+
for row in rows:
|
|
106
|
+
fh.write(json.dumps(row, ensure_ascii=False))
|
|
107
|
+
fh.write("\n")
|
|
108
|
+
print(f"wrote {len(rows)} rows -> {path}", file=sys.stderr)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
113
|
+
p = argparse.ArgumentParser(
|
|
114
|
+
prog="simphone",
|
|
115
|
+
description="Find phonetically similar words or spans with a feature-weighted edit distance.",
|
|
116
|
+
)
|
|
117
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
118
|
+
|
|
119
|
+
n = sub.add_parser("norm", help="expand spans to their spoken form")
|
|
120
|
+
n.add_argument("--lang", required=True, choices=["vi", "en"])
|
|
121
|
+
n.add_argument("--text", action="append", default=None, help="repeatable span")
|
|
122
|
+
n.add_argument("--queries", default=None, help="file of spans, one per line")
|
|
123
|
+
n.add_argument(
|
|
124
|
+
"--workers",
|
|
125
|
+
type=int,
|
|
126
|
+
default=0,
|
|
127
|
+
help="sea-g2p Rayon threads for Vietnamese (0 = os.cpu_count()). Ignored for English.",
|
|
128
|
+
)
|
|
129
|
+
n.set_defaults(func=cmd_norm)
|
|
130
|
+
|
|
131
|
+
b = sub.add_parser("build", help="phonemize an inventory and save the index")
|
|
132
|
+
b.add_argument("--lang", required=True, choices=["vi", "en"])
|
|
133
|
+
b.add_argument("--inventory", required=True, help="one span per line")
|
|
134
|
+
b.add_argument("--save", required=True, help="directory to write")
|
|
135
|
+
b.add_argument("--tone-weight", type=float, default=0.5)
|
|
136
|
+
b.add_argument("--device", default="cpu", help="English out-of-dictionary G2P device")
|
|
137
|
+
b.add_argument(
|
|
138
|
+
"--workers",
|
|
139
|
+
type=int,
|
|
140
|
+
default=0,
|
|
141
|
+
help="CPU workers for Vietnamese segmentation (0 = os.cpu_count()). "
|
|
142
|
+
"Also sets sea-g2p's Rayon thread count.",
|
|
143
|
+
)
|
|
144
|
+
b.add_argument("--cache-dir", default=None, help="Charsiu model cache (English)")
|
|
145
|
+
b.add_argument("--en-locale", default="eng-us")
|
|
146
|
+
b.set_defaults(func=cmd_build)
|
|
147
|
+
|
|
148
|
+
s = sub.add_parser("search", help="search one or more queries against an inventory")
|
|
149
|
+
s.add_argument("--load", default=None, help="index directory from build")
|
|
150
|
+
s.add_argument("--lang", choices=["vi", "en"], help="required with --inventory")
|
|
151
|
+
s.add_argument("--inventory", default=None, help="build in memory instead of --load")
|
|
152
|
+
s.add_argument("--query", action="append", default=None, help="repeatable span")
|
|
153
|
+
s.add_argument("--queries", default=None, help="file of query spans, one per line")
|
|
154
|
+
s.add_argument("--topk", type=int, default=50)
|
|
155
|
+
s.add_argument("--prefilter", type=int, default=300)
|
|
156
|
+
s.add_argument("--sim-min", type=float, default=0.8)
|
|
157
|
+
s.add_argument("--workers", type=int, default=0, help="0 = os.cpu_count()")
|
|
158
|
+
s.add_argument("--block-size", type=int, default=512)
|
|
159
|
+
s.add_argument("--tone-weight", type=float, default=0.5)
|
|
160
|
+
s.add_argument("--device", default="cpu")
|
|
161
|
+
s.add_argument("--cache-dir", default=None)
|
|
162
|
+
s.add_argument("--en-locale", default="eng-us")
|
|
163
|
+
s.add_argument("--out", default=None, help="JSONL of query rows")
|
|
164
|
+
s.set_defaults(func=cmd_search)
|
|
165
|
+
return p
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv=None) -> int:
|
|
169
|
+
args = build_parser().parse_args(argv)
|
|
170
|
+
return args.func(args)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
sys.exit(main())
|
simphone/distance.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Feature-weighted phonetic edit distance.
|
|
2
|
+
|
|
3
|
+
Substitution cost is the panphon articulatory distance between base phones,
|
|
4
|
+
normalized to ``[0, 1]``, plus ``tone_weight`` when the tone marks differ.
|
|
5
|
+
panphon ignores tone, so the tone term is what keeps tone in the score.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
# Charsiu English tones: Chao letters U+02E5..U+02E9, sometimes with U+02C0 (ˀ).
|
|
15
|
+
CHAO_TONE_CHARS = frozenset(chr(c) for c in range(0x02E5, 0x02EA))
|
|
16
|
+
CHAO_EXTRA = frozenset({"\u02C0"})
|
|
17
|
+
|
|
18
|
+
# sea-g2p Vietnamese tones, peeled out of the syllable before segmentation.
|
|
19
|
+
# Confirmed on ma/mà/mả/mã/má/mạ: huyền=2, hỏi=4, ngã=5, nặng=6, sắc=U+025C (ɜ).
|
|
20
|
+
# Ngang has no mark. ɜ is a tone glyph here, not the IPA vowel.
|
|
21
|
+
VI_TONE_MARKS = frozenset({"2", "4", "5", "6", "\u025c"})
|
|
22
|
+
|
|
23
|
+
TONE_CHARS = CHAO_TONE_CHARS | CHAO_EXTRA | VI_TONE_MARKS
|
|
24
|
+
|
|
25
|
+
_DP_FUNC = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_tone_token(token: str) -> bool:
|
|
29
|
+
"""True if ``token`` is only Chao tone letters (and optional ˀ)."""
|
|
30
|
+
return bool(token) and any(c in CHAO_TONE_CHARS for c in token) and all(
|
|
31
|
+
c in CHAO_TONE_CHARS or c in CHAO_EXTRA for c in token
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def split_base_tone(token: str) -> tuple[str, str]:
|
|
36
|
+
"""Split a phone token into ``(base_ipa, tone_str)``.
|
|
37
|
+
|
|
38
|
+
Chao-only tokens yield ``("", tone)``. A sea-g2p vowel with a trailing
|
|
39
|
+
Vietnamese tone mark (``aː2``, ``aɜ``) yields the base vowel and that mark.
|
|
40
|
+
A one-character ``ɜ`` is left as a base phone so an English NURSE vowel is
|
|
41
|
+
not treated as Vietnamese sắc.
|
|
42
|
+
"""
|
|
43
|
+
if is_tone_token(token):
|
|
44
|
+
return "", token
|
|
45
|
+
if len(token) > 1 and token[-1] in VI_TONE_MARKS:
|
|
46
|
+
return token[:-1], token[-1]
|
|
47
|
+
base = "".join(c for c in token if c not in CHAO_TONE_CHARS and c not in CHAO_EXTRA)
|
|
48
|
+
tone = "".join(c for c in token if c in CHAO_TONE_CHARS or c in CHAO_EXTRA)
|
|
49
|
+
return base, tone
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def span_phones(
|
|
53
|
+
units: Sequence[str], unit_tokens: dict[str, Sequence[str]]
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
"""Concatenate per-unit phone-token lists (no word-boundary token)."""
|
|
56
|
+
toks: list[str] = []
|
|
57
|
+
for u in units:
|
|
58
|
+
toks.extend(unit_tokens.get(u, ()))
|
|
59
|
+
return toks
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_dp():
|
|
63
|
+
"""Weighted Levenshtein ``dp(a, b, C, indel) -> float``. Numba when available."""
|
|
64
|
+
global _DP_FUNC
|
|
65
|
+
if _DP_FUNC is not None:
|
|
66
|
+
return _DP_FUNC
|
|
67
|
+
|
|
68
|
+
def _dp(a, b, C, indel):
|
|
69
|
+
la = a.shape[0]
|
|
70
|
+
lb = b.shape[0]
|
|
71
|
+
if la == 0:
|
|
72
|
+
return lb * indel
|
|
73
|
+
if lb == 0:
|
|
74
|
+
return la * indel
|
|
75
|
+
prev = np.empty(lb + 1, dtype=np.float32)
|
|
76
|
+
cur = np.empty(lb + 1, dtype=np.float32)
|
|
77
|
+
for j in range(lb + 1):
|
|
78
|
+
prev[j] = j * indel
|
|
79
|
+
for i in range(1, la + 1):
|
|
80
|
+
cur[0] = i * indel
|
|
81
|
+
ai = a[i - 1]
|
|
82
|
+
for j in range(1, lb + 1):
|
|
83
|
+
sub = prev[j - 1] + C[ai, b[j - 1]]
|
|
84
|
+
dele = prev[j] + indel
|
|
85
|
+
ins = cur[j - 1] + indel
|
|
86
|
+
m = sub
|
|
87
|
+
if dele < m:
|
|
88
|
+
m = dele
|
|
89
|
+
if ins < m:
|
|
90
|
+
m = ins
|
|
91
|
+
cur[j] = m
|
|
92
|
+
for j in range(lb + 1):
|
|
93
|
+
prev[j] = cur[j]
|
|
94
|
+
return prev[lb]
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
from numba import njit
|
|
98
|
+
|
|
99
|
+
_DP_FUNC = njit(cache=True)(_dp)
|
|
100
|
+
except Exception:
|
|
101
|
+
_DP_FUNC = _dp
|
|
102
|
+
return _DP_FUNC
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PhoneticDistance:
|
|
106
|
+
"""Cost matrix and weighted edit distance over a fixed phone-token list."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
tokens: Sequence[str],
|
|
111
|
+
*,
|
|
112
|
+
tone_weight: float = 0.5,
|
|
113
|
+
indel_cost: float = 1.0,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.tone_weight = float(tone_weight)
|
|
116
|
+
self.indel_cost = float(indel_cost)
|
|
117
|
+
self.tokens = list(dict.fromkeys(tokens))
|
|
118
|
+
self.tok2id = {t: i for i, t in enumerate(self.tokens)}
|
|
119
|
+
self.cost_scale = 1.0
|
|
120
|
+
self._bases: list[str] = []
|
|
121
|
+
self._Cb: Optional[np.ndarray] = None
|
|
122
|
+
self._tok_base: list[int] = []
|
|
123
|
+
self._tok_tone: list[str] = []
|
|
124
|
+
self.cost = self._build_cost()
|
|
125
|
+
self._dp = _get_dp()
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_matrix(
|
|
129
|
+
cls,
|
|
130
|
+
tokens: Sequence[str],
|
|
131
|
+
cost: np.ndarray,
|
|
132
|
+
*,
|
|
133
|
+
tone_weight: float,
|
|
134
|
+
indel_cost: float,
|
|
135
|
+
cost_scale: float,
|
|
136
|
+
) -> "PhoneticDistance":
|
|
137
|
+
"""Restore a matrix saved by :meth:`PhoneticIndex.save` (no panphon)."""
|
|
138
|
+
obj = cls.__new__(cls)
|
|
139
|
+
obj.tone_weight = float(tone_weight)
|
|
140
|
+
obj.indel_cost = float(indel_cost)
|
|
141
|
+
obj.tokens = list(tokens)
|
|
142
|
+
obj.tok2id = {t: i for i, t in enumerate(obj.tokens)}
|
|
143
|
+
obj.cost = np.asarray(cost, dtype=np.float32)
|
|
144
|
+
obj.cost_scale = float(cost_scale) if cost_scale and cost_scale > 0 else 1.0
|
|
145
|
+
obj._dp = _get_dp()
|
|
146
|
+
obj._recover_components()
|
|
147
|
+
return obj
|
|
148
|
+
|
|
149
|
+
def _base_cost_matrix(self, bases: list[str]) -> tuple[np.ndarray, float]:
|
|
150
|
+
"""Normalized ``[0,1]`` panphon distances. Empty base is the null segment."""
|
|
151
|
+
from panphon.distance import Distance
|
|
152
|
+
|
|
153
|
+
dist = Distance()
|
|
154
|
+
n = len(bases)
|
|
155
|
+
raw = np.zeros((n, n), dtype=np.float64)
|
|
156
|
+
from tqdm import tqdm
|
|
157
|
+
|
|
158
|
+
real = [i for i, b in enumerate(bases) if b]
|
|
159
|
+
for x in tqdm(range(len(real)), desc="panphon", unit="phone"):
|
|
160
|
+
for y in range(x + 1, len(real)):
|
|
161
|
+
i, j = real[x], real[y]
|
|
162
|
+
try:
|
|
163
|
+
v = float(dist.weighted_feature_edit_distance(bases[i], bases[j]))
|
|
164
|
+
except Exception:
|
|
165
|
+
v = -1.0
|
|
166
|
+
if v != v:
|
|
167
|
+
v = -1.0
|
|
168
|
+
raw[i, j] = raw[j, i] = v
|
|
169
|
+
finite = raw[raw > 0]
|
|
170
|
+
scale = float(finite.max()) if finite.size else 1.0
|
|
171
|
+
if scale <= 0:
|
|
172
|
+
scale = 1.0
|
|
173
|
+
raw[raw < 0] = scale
|
|
174
|
+
raw = raw / scale
|
|
175
|
+
for i, b in enumerate(bases):
|
|
176
|
+
if not b:
|
|
177
|
+
for j in range(n):
|
|
178
|
+
raw[i, j] = raw[j, i] = 0.0 if i == j else 1.0
|
|
179
|
+
return raw, scale
|
|
180
|
+
|
|
181
|
+
def _split_all(self, tokens: Sequence[str]) -> tuple[list[str], list[str]]:
|
|
182
|
+
bases: list[str] = []
|
|
183
|
+
tones: list[str] = []
|
|
184
|
+
for t in tokens:
|
|
185
|
+
b, tn = split_base_tone(t)
|
|
186
|
+
bases.append(b)
|
|
187
|
+
tones.append(tn)
|
|
188
|
+
return bases, tones
|
|
189
|
+
|
|
190
|
+
def _build_cost(self) -> np.ndarray:
|
|
191
|
+
bases, tones = self._split_all(self.tokens)
|
|
192
|
+
uniq = list(dict.fromkeys(bases))
|
|
193
|
+
base2id = {b: i for i, b in enumerate(uniq)}
|
|
194
|
+
Cb, scale = self._base_cost_matrix(uniq)
|
|
195
|
+
self._bases = uniq
|
|
196
|
+
self._Cb = Cb
|
|
197
|
+
self.cost_scale = scale
|
|
198
|
+
self._tok_base = [base2id[b] for b in bases]
|
|
199
|
+
self._tok_tone = tones
|
|
200
|
+
return self._cost_from_components(self._tok_base, self._tok_tone)
|
|
201
|
+
|
|
202
|
+
def _cost_from_components(self, bidx: Sequence[int], tones: Sequence[str]) -> np.ndarray:
|
|
203
|
+
assert self._Cb is not None
|
|
204
|
+
k = len(bidx)
|
|
205
|
+
C = np.zeros((k, k), dtype=np.float32)
|
|
206
|
+
for i in range(k):
|
|
207
|
+
bi = bidx[i]
|
|
208
|
+
ti = tones[i]
|
|
209
|
+
for j in range(k):
|
|
210
|
+
c = float(self._Cb[bi, bidx[j]])
|
|
211
|
+
if ti != tones[j]:
|
|
212
|
+
c += self.tone_weight
|
|
213
|
+
C[i, j] = c
|
|
214
|
+
np.fill_diagonal(C, 0.0)
|
|
215
|
+
return C
|
|
216
|
+
|
|
217
|
+
def _recover_components(self) -> None:
|
|
218
|
+
"""Rebuild the base-cost matrix from the saved full-token matrix."""
|
|
219
|
+
bases, tones = self._split_all(self.tokens)
|
|
220
|
+
uniq = list(dict.fromkeys(bases))
|
|
221
|
+
base2id = {b: i for i, b in enumerate(uniq)}
|
|
222
|
+
first: dict[str, int] = {}
|
|
223
|
+
for i, b in enumerate(bases):
|
|
224
|
+
first.setdefault(b, i)
|
|
225
|
+
n = len(uniq)
|
|
226
|
+
Cb = np.zeros((n, n), dtype=np.float64)
|
|
227
|
+
for a in uniq:
|
|
228
|
+
ia = first[a]
|
|
229
|
+
for b in uniq:
|
|
230
|
+
ib = first[b]
|
|
231
|
+
c = float(self.cost[ia, ib])
|
|
232
|
+
if tones[ia] != tones[ib]:
|
|
233
|
+
c -= self.tone_weight
|
|
234
|
+
if c < 0.0:
|
|
235
|
+
c = 0.0
|
|
236
|
+
Cb[base2id[a], base2id[b]] = c
|
|
237
|
+
self._bases = uniq
|
|
238
|
+
self._Cb = Cb
|
|
239
|
+
self._tok_base = [base2id[b] for b in bases]
|
|
240
|
+
self._tok_tone = tones
|
|
241
|
+
|
|
242
|
+
def _pair_base(self, dist, a: str, b: str) -> float:
|
|
243
|
+
if a == b:
|
|
244
|
+
return 0.0
|
|
245
|
+
if not a or not b:
|
|
246
|
+
return 1.0
|
|
247
|
+
try:
|
|
248
|
+
v = float(dist.weighted_feature_edit_distance(a, b))
|
|
249
|
+
except Exception:
|
|
250
|
+
return 1.0
|
|
251
|
+
if v != v or v < 0:
|
|
252
|
+
return 1.0
|
|
253
|
+
scale = self.cost_scale if self.cost_scale > 0 else 1.0
|
|
254
|
+
out = v / scale
|
|
255
|
+
return 1.0 if out > 1.0 else out
|
|
256
|
+
|
|
257
|
+
def ensure_tokens(self, tokens: Sequence[str]) -> None:
|
|
258
|
+
"""Append phones that are not in the matrix.
|
|
259
|
+
|
|
260
|
+
Existing inventory ids stay valid. A new base phone gets panphon costs
|
|
261
|
+
against the bases already stored; the saved inventory block is not rebuilt.
|
|
262
|
+
"""
|
|
263
|
+
new: list[str] = []
|
|
264
|
+
for t in tokens:
|
|
265
|
+
if t not in self.tok2id and t not in new:
|
|
266
|
+
new.append(t)
|
|
267
|
+
if not new:
|
|
268
|
+
return
|
|
269
|
+
assert self._Cb is not None
|
|
270
|
+
from panphon.distance import Distance
|
|
271
|
+
|
|
272
|
+
dist = Distance()
|
|
273
|
+
new_bases, new_tones = self._split_all(new)
|
|
274
|
+
extra: list[str] = []
|
|
275
|
+
known = set(self._bases)
|
|
276
|
+
for b in new_bases:
|
|
277
|
+
if b not in known:
|
|
278
|
+
known.add(b)
|
|
279
|
+
extra.append(b)
|
|
280
|
+
if extra:
|
|
281
|
+
old_n = len(self._bases)
|
|
282
|
+
add_n = len(extra)
|
|
283
|
+
grown = np.zeros((old_n + add_n, old_n + add_n), dtype=np.float64)
|
|
284
|
+
grown[:old_n, :old_n] = self._Cb
|
|
285
|
+
for i, b in enumerate(extra):
|
|
286
|
+
bi = old_n + i
|
|
287
|
+
for j, ob in enumerate(self._bases):
|
|
288
|
+
grown[bi, j] = grown[j, bi] = self._pair_base(dist, b, ob)
|
|
289
|
+
for k in range(i):
|
|
290
|
+
grown[bi, old_n + k] = grown[old_n + k, bi] = self._pair_base(
|
|
291
|
+
dist, b, extra[k]
|
|
292
|
+
)
|
|
293
|
+
self._bases = list(self._bases) + extra
|
|
294
|
+
self._Cb = grown
|
|
295
|
+
base2id = {b: i for i, b in enumerate(self._bases)}
|
|
296
|
+
old_k = len(self.tokens)
|
|
297
|
+
bidx = self._tok_base + [base2id[b] for b in new_bases]
|
|
298
|
+
tones = self._tok_tone + new_tones
|
|
299
|
+
C = np.zeros((old_k + len(new), old_k + len(new)), dtype=np.float32)
|
|
300
|
+
C[:old_k, :old_k] = self.cost
|
|
301
|
+
for i in range(old_k, old_k + len(new)):
|
|
302
|
+
for j in range(old_k + len(new)):
|
|
303
|
+
if i == j:
|
|
304
|
+
continue
|
|
305
|
+
c = float(self._Cb[bidx[i], bidx[j]])
|
|
306
|
+
if tones[i] != tones[j]:
|
|
307
|
+
c += self.tone_weight
|
|
308
|
+
C[i, j] = C[j, i] = c
|
|
309
|
+
np.fill_diagonal(C, 0.0)
|
|
310
|
+
self.cost = C
|
|
311
|
+
for i, t in enumerate(new):
|
|
312
|
+
self.tok2id[t] = old_k + i
|
|
313
|
+
self.tokens.extend(new)
|
|
314
|
+
self._tok_base = bidx
|
|
315
|
+
self._tok_tone = tones
|
|
316
|
+
|
|
317
|
+
def id_char_map(self, base: int = 0x100) -> list[str]:
|
|
318
|
+
"""One distinct unicode char per token id, for token-level rapidfuzz."""
|
|
319
|
+
out: list[str] = []
|
|
320
|
+
for idx in range(len(self.tokens)):
|
|
321
|
+
c = base + idx
|
|
322
|
+
if 0xD800 <= c <= 0xDFFF:
|
|
323
|
+
c += 0x800
|
|
324
|
+
out.append(chr(c))
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
def encode(self, tokens: Sequence[str]) -> np.ndarray:
|
|
328
|
+
"""Phone tokens to int32 ids. Unknown tokens are dropped."""
|
|
329
|
+
return np.array(
|
|
330
|
+
[self.tok2id[t] for t in tokens if t in self.tok2id], dtype=np.int32
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def distance(self, a_ids: np.ndarray, b_ids: np.ndarray) -> float:
|
|
334
|
+
return float(self._dp(a_ids, b_ids, self.cost, self.indel_cost))
|
|
335
|
+
|
|
336
|
+
def normalized_similarity(self, a_ids: np.ndarray, b_ids: np.ndarray) -> float:
|
|
337
|
+
la, lb = int(a_ids.shape[0]), int(b_ids.shape[0])
|
|
338
|
+
m = max(la, lb)
|
|
339
|
+
if m == 0:
|
|
340
|
+
return 1.0
|
|
341
|
+
sim = 1.0 - self.distance(a_ids, b_ids) / m
|
|
342
|
+
if sim < 0.0:
|
|
343
|
+
return 0.0
|
|
344
|
+
if sim > 1.0:
|
|
345
|
+
return 1.0
|
|
346
|
+
return sim
|