Skip to content

Commit 70d8710

Browse files
jqueguinerclaude
andcommitted
feat(naming): homophone detection with probabilities
Add realnames.homophones(name, country) — groups same-sounding names in a country by double-metaphone and weights each by country-wide frequency share, returning probabilities that sum to 1. E.g. Dominique/FR -> Dominique 0.91, Dominic 0.03, Dominik 0.02, ... Dataset gains two columns (parquet rebuilt, 18.8MB): country_share (relative share within a country, cross-gender comparable, still no raw counts) and phonetic (double-metaphone). Tests + README updated; naming coverage 97%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fba8e30 commit 70d8710

5 files changed

Lines changed: 84 additions & 3 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ realnames.first_name_like("Jacques", "FR") # frequency-weighted male FR name
3939
realnames.first_name("JP", "f") # weighted female Japanese name
4040
realnames.detect_country("Yuki") # [("JP", 0.58), ("CN", 0.05), ...]
4141
realnames.detect_country("Bjorn") # [("SE", 0.29), ("NO", 0.22), ...]
42+
realnames.homophones("Dominique", "FR") # [("Dominique", 0.91), ("Dominic", 0.03), ...]
4243

4344
grammar.pluralize("baby") # "babies"
4445
grammar.agree(3, "dog") # "3 dogs"
@@ -51,6 +52,10 @@ variant `faker2.naming.gender` has no extra dependency.
5152
frequency share), not where the most *people* with that name live — raw
5253
population counts are intentionally not in the dataset.
5354

55+
`homophones` groups same-sounding names in a country by double-metaphone and
56+
weights them by frequency share (probabilities sum to 1). Double-metaphone is
57+
coarse, so results may include near-homophones.
58+
5459
## Rust port
5560

5661
```

data/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ Format: Parquet, zstd-compressed, dictionary-encoded low-cardinality columns.
1515

1616
## Schema (`first_names`)
1717

18-
`name, name_ascii, country_code, country_name, continent, gender, unisex, country_rank, frequency`
18+
`name, name_ascii, country_code, country_name, continent, gender, unisex, country_rank, frequency, country_share, phonetic`
19+
20+
- `frequency` — relative share within `(country_code, gender)` (weighted sampling).
21+
- `country_share` — relative share within `country_code` (cross-gender comparable; powers `detect_country` / `homophones` probabilities).
22+
- `phonetic` — double-metaphone key (powers `homophones`).
1923

2024
`frequency` is a **relative share** (0..1) within each `(country_code, gender)`
2125
group — it preserves the weighting used for sampling but deliberately carries

data/first_names.parquet

3.16 MB
Binary file not shown.

faker2/naming/realnames.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,19 @@ def __init__(self, path: str = _DATA) -> None:
3838
import pyarrow.parquet as pq # local import: optional dependency
3939

4040
tbl = pq.read_table(
41-
path, columns=["name", "name_ascii", "country_code", "gender", "frequency"]
41+
path,
42+
columns=[
43+
"name", "name_ascii", "country_code", "gender",
44+
"frequency", "country_share", "phonetic",
45+
],
4246
)
4347
names = tbl.column("name").to_pylist()
4448
asciis = tbl.column("name_ascii").to_pylist()
4549
ccs = tbl.column("country_code").to_pylist()
4650
genders = tbl.column("gender").to_pylist()
4751
freqs = tbl.column("frequency").to_pylist()
52+
shares = tbl.column("country_share").to_pylist()
53+
phonetics = tbl.column("phonetic").to_pylist()
4854

4955
# (country, gender) -> (names[], cumulative_weights[]).
5056
# Weights are *relative* frequencies (shares), never raw counts.
@@ -53,12 +59,21 @@ def __init__(self, path: str = _DATA) -> None:
5359
gender_by: Dict[Tuple[str, Optional[str]], Dict[str, float]] = {}
5460
# name_key -> {country: summed within-country share} for origin detection
5561
country_by: Dict[str, Dict[str, float]] = {}
62+
# (name_key, country) -> phonetic key; (country, phonetic) -> {name: share}
63+
phon_key: Dict[Tuple[str, str], str] = {}
64+
homo: Dict[Tuple[str, str], Dict[str, float]] = {}
5665

5766
acc: Dict[Tuple[Optional[str], str], List] = {}
58-
for name, asc, cc, g, fr in zip(names, asciis, ccs, genders, freqs):
67+
for name, asc, cc, g, fr, cs, phon in zip(
68+
names, asciis, ccs, genders, freqs, shares, phonetics
69+
):
5970
if not g or g == " ":
6071
continue
6172
w = float(fr) if fr else 1e-6
73+
csh = float(cs) if cs else 1e-9
74+
if cc and phon:
75+
homo.setdefault((cc, phon), {})
76+
homo[(cc, phon)][name] = homo[(cc, phon)].get(name, 0.0) + csh
6277
for scope in (cc, None): # per-country and global pools
6378
key = (scope, g)
6479
lst = acc.setdefault(key, [[], []])
@@ -72,6 +87,8 @@ def __init__(self, path: str = _DATA) -> None:
7287
if cc:
7388
c = country_by.setdefault(akey, {})
7489
c[cc] = c.get(cc, 0.0) + w
90+
if phon:
91+
phon_key[(akey, cc)] = phon
7592

7693
# finalize cumulative weights for O(log n) weighted draw
7794
for key, (nm, wt) in acc.items():
@@ -84,6 +101,8 @@ def __init__(self, path: str = _DATA) -> None:
84101
self._pools = pools
85102
self._gender_by = gender_by
86103
self._country_by = country_by
104+
self._phon_key = phon_key
105+
self._homo = homo
87106

88107
def infer(self, name: str, country: Optional[str] = None) -> Optional[str]:
89108
key = name.strip().lower()
@@ -119,6 +138,29 @@ def draw(self, country: Optional[str], gender: str, avoid: Optional[str] = None)
119138
def countries(self) -> List[str]:
120139
return sorted({cc for (cc, _g) in self._pools if cc})
121140

141+
def homophones(
142+
self, name: str, country: str, top: int = 10, include_self: bool = True
143+
) -> List[Tuple[str, float]]:
144+
"""Names that sound like ``name`` in ``country``, with probabilities.
145+
146+
Groups names by phonetic key (double-metaphone) within the country and
147+
weights each by its country-wide frequency share. Returns
148+
``[(name, probability)]`` (probabilities over the group sum to 1),
149+
highest first.
150+
"""
151+
phon = self._phon_key.get((name.strip().lower(), country))
152+
if not phon:
153+
return []
154+
group = self._homo.get((country, phon))
155+
if not group:
156+
return []
157+
items = list(group.items())
158+
if not include_self:
159+
items = [(n, w) for n, w in items if n.lower() != name.strip().lower()]
160+
total = sum(w for _n, w in items) or 1.0
161+
ranked = sorted(items, key=lambda kv: kv[1], reverse=True)
162+
return [(n, w / total) for n, w in ranked[:top]]
163+
122164
def detect_country(self, name: str, top: int = 5) -> List[Tuple[str, float]]:
123165
"""Rank the countries where ``name`` is most characteristic.
124166
@@ -158,6 +200,20 @@ def detect_country(name: str, top: int = 5) -> List[Tuple[str, float]]:
158200
return _bank().detect_country(name, top)
159201

160202

203+
def homophones(
204+
name: str, country: str, top: int = 10, include_self: bool = True
205+
) -> List[Tuple[str, float]]:
206+
"""Same-sounding names in a country, with probabilities.
207+
208+
homophones("Dominique", "FR")
209+
# [("Dominique", 0.91), ("Dominic", 0.03), ("Dominik", 0.02), ...]
210+
211+
Groups by double-metaphone within ``country`` and weights by country-wide
212+
frequency share. Probabilities sum to 1. ``[]`` if the name is unknown.
213+
"""
214+
return _bank().homophones(name, country.upper() if country else "", top, include_self)
215+
216+
161217
def first_name(country: Optional[str] = None, gender: str = MALE) -> Optional[str]:
162218
"""Frequency-weighted first name for a country + gender."""
163219
return _bank().draw(country.upper() if country else None, gender)

tests/test_realnames.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ def test_detect_country():
7373
assert len(rn.detect_country("Maria", top=2)) <= 2 # top-N respected
7474

7575

76+
def test_homophones():
77+
h = rn.homophones("Dominique", "FR", top=6)
78+
names = [n for n, _p in h]
79+
assert h[0][0] == "Dominique" # most frequent variant first
80+
assert "Dominic" in names and "Dominik" in names # same-sounding variants
81+
assert abs(sum(p for _n, p in rn.homophones("Dominique", "FR", top=999)) - 1.0) < 1e-6
82+
# symmetric: Dominic sees the same group
83+
assert "Dominique" in [n for n, _ in rn.homophones("Dominic", "FR")]
84+
# exclude_self drops the query name
85+
assert "Dominique" not in [
86+
n for n, _ in rn.homophones("Dominique", "FR", include_self=False)
87+
]
88+
assert rn.homophones("Zzxqwv", "FR") == [] # unknown -> empty
89+
assert len(rn.homophones("Marc", "FR", top=3)) <= 3 # top-N respected
90+
91+
7692
def test_seed_reproducible():
7793
Faker.seed(99)
7894
a = [rn.first_name_like("Jacques", "FR") for _ in range(5)]

0 commit comments

Comments
 (0)