genderize-offline 0.1.0__tar.gz
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.
- genderize_offline-0.1.0/.gitignore +21 -0
- genderize_offline-0.1.0/CHANGELOG.md +21 -0
- genderize_offline-0.1.0/LICENSE +21 -0
- genderize_offline-0.1.0/PKG-INFO +168 -0
- genderize_offline-0.1.0/README.md +141 -0
- genderize_offline-0.1.0/benchmarks/benchmark.py +77 -0
- genderize_offline-0.1.0/pyproject.toml +64 -0
- genderize_offline-0.1.0/src/genderize_offline/__init__.py +12 -0
- genderize_offline-0.1.0/src/genderize_offline/__main__.py +3 -0
- genderize_offline-0.1.0/src/genderize_offline/cli.py +58 -0
- genderize_offline-0.1.0/src/genderize_offline/core.py +87 -0
- genderize_offline-0.1.0/src/genderize_offline/country_iso2.json +110 -0
- genderize_offline-0.1.0/src/genderize_offline/dictionary.py +134 -0
- genderize_offline-0.1.0/src/genderize_offline/py.typed +0 -0
- genderize_offline-0.1.0/tests/conftest.py +35 -0
- genderize_offline-0.1.0/tests/test_cli.py +38 -0
- genderize_offline-0.1.0/tests/test_core.py +65 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.coverage
|
|
11
|
+
coverage.xml
|
|
12
|
+
htmlcov/
|
|
13
|
+
# Add here any generated outputs or data files that hold personal data (e.g. downloaded pages)
|
|
14
|
+
# Never commit credentials
|
|
15
|
+
.env
|
|
16
|
+
.pypirc
|
|
17
|
+
*apikey*
|
|
18
|
+
# Dictionaries and benchmark inputs (large; names of real people)
|
|
19
|
+
*.tsv.gz
|
|
20
|
+
wgnd*.csv
|
|
21
|
+
test_gender*.csv
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-03
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `guess(names, country)` and `genderize(names, country_id)` (genderize.io-shaped answer),
|
|
14
|
+
`country_code`, `tokens`, `p_female`.
|
|
15
|
+
- Command `genderize-offline` with `name`, `csv`, `fetch` and `build`.
|
|
16
|
+
- Compact dictionary built from WGND 2.0, published as the GitHub release `wgnd-2.0-r1`
|
|
17
|
+
and downloaded on first use with a SHA-256 check; fallback build from Harvard Dataverse.
|
|
18
|
+
- Benchmarks against genderize.io (`benchmarks/benchmark.py`, README).
|
|
19
|
+
|
|
20
|
+
[Unreleased]: https://github.com/restrepo/genderize-offline/compare/v0.1.0...HEAD
|
|
21
|
+
[0.1.0]: https://github.com/restrepo/genderize-offline/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Restrepo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: genderize-offline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline gender inference from first names and country, a free drop-in for genderize.io, using the World Gender Name Dictionary 2.0.
|
|
5
|
+
Project-URL: Homepage, https://github.com/restrepo/genderize-offline
|
|
6
|
+
Project-URL: Repository, https://github.com/restrepo/genderize-offline
|
|
7
|
+
Project-URL: Issues, https://github.com/restrepo/genderize-offline/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/restrepo/genderize-offline/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Diego Restrepo <restrepo@udea.edu.co>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: WGND,bibliometrics,first names,gender,genderize,names,offline
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff==0.17.3; extra == 'dev'
|
|
25
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# genderize-offline
|
|
29
|
+
|
|
30
|
+
[](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/genderize-offline/)
|
|
32
|
+
[](https://pypi.org/project/genderize-offline/)
|
|
33
|
+
|
|
34
|
+
Infer gender from first names and country **offline**: a free drop-in for
|
|
35
|
+
[genderize.io](https://genderize.io), with no API key, no quota and no network
|
|
36
|
+
after the first use. It uses the
|
|
37
|
+
[World Gender Name Dictionary 2.0](https://doi.org/10.7910/DVN/MSEGSJ) (WGND, Raffo 2021,
|
|
38
|
+
CC0), which gives the share of women and men for 4.9 million name–country pairs.
|
|
39
|
+
Standard library only.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install genderize-offline
|
|
45
|
+
genderize-offline fetch # once: downloads the dictionary (14 MB) into the cache
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Use
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from genderize_offline import guess, genderize
|
|
52
|
+
|
|
53
|
+
guess("Natalia Andrea", "CO") # ('female', 1.0, 58)
|
|
54
|
+
guess("Andrea", "Italy") # ('male', 1.0, 77) country names or ISO2 codes
|
|
55
|
+
guess("Andrea", "CO") # ('female', 0.96, 77)
|
|
56
|
+
genderize("Andrea", "IT") # {'count': 77, 'name': 'Andrea', 'gender': 'male', 'probability': 1.0, 'country_id': 'IT'}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`guess(names, country)` returns `(gender, probability, count)`: gender `male`, `female` or
|
|
60
|
+
`unknown`; probability 0.5–1 (0 for unknown); count, the number of countries that list the
|
|
61
|
+
name (genderize.io's count is people). `genderize()` answers in genderize.io's shape, with
|
|
62
|
+
`gender` `None` when unknown. As with genderize.io, set your own threshold: the benchmarks
|
|
63
|
+
below count a gender only at probability ≥ 0.9.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
genderize-offline name "María José" ES
|
|
67
|
+
genderize-offline csv authors.csv out.csv --name-col first_names --country-col country
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Method
|
|
71
|
+
|
|
72
|
+
- Names are lowercased and split on spaces, hyphens and dots; initials are dropped.
|
|
73
|
+
- The first given name found in the dictionary decides (genderize.io also uses the first word).
|
|
74
|
+
- The share of women is WGND's for that name in the given country; if the country is missing
|
|
75
|
+
or doesn't list the name, the average over every country that lists it is used.
|
|
76
|
+
- Accents are optional: `andres` finds `andrés`, `josé` finds `jose`.
|
|
77
|
+
|
|
78
|
+
## Benchmarks
|
|
79
|
+
|
|
80
|
+
Run with `python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json]` (threshold 0.9). The inputs
|
|
81
|
+
hold names of real people and are not in this repository; they are files of
|
|
82
|
+
[restrepo/inspireauth](https://github.com/restrepo/inspireauth).
|
|
83
|
+
|
|
84
|
+
**Hand-labelled names** (`data/test_gender.csv`: 3,965 names, 3,820 of them Colombian, with
|
|
85
|
+
their true gender and genderize.io's answer):
|
|
86
|
+
|
|
87
|
+
| | genderize.io | genderize-offline |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| Names given a gender (coverage) | 98.1% | 93.6% |
|
|
90
|
+
| Accuracy where a gender is given | 99.38% | 98.98% |
|
|
91
|
+
|
|
92
|
+
**INSPIRE-HEP authors with Latin American affiliations** (`data/inspire_LA.json`: 29,979
|
|
93
|
+
physicists, genders from genderize.io):
|
|
94
|
+
|
|
95
|
+
| genderize.io ↓ / offline → | male | female | unknown |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| male | 14,479 | 39 | 686 |
|
|
98
|
+
| female | 23 | 2,635 | 155 |
|
|
99
|
+
| unknown | 677 | 176 | 11,109 |
|
|
100
|
+
|
|
101
|
+
| | genderize.io | genderize-offline |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Authors with a gender | 18,017 | 18,029 |
|
|
104
|
+
| Female share | 15.6% | 15.8% |
|
|
105
|
+
|
|
106
|
+
Where both give a gender (17,176 authors) they agree on **99.64%**.
|
|
107
|
+
|
|
108
|
+
**Effect on published results.** We recomputed the female fractions of *Statistical Analysis
|
|
109
|
+
of Scientific Metrics in High Energy, Cosmology, and Astroparticle Physics in Latin America*
|
|
110
|
+
(Morales-Alvarado, Quevedo, Ramos-Hamud, Restrepo) with offline genders. Every value moves
|
|
111
|
+
by less than its 95% margin:
|
|
112
|
+
|
|
113
|
+
| Female share by rank (Table 8) | genderize.io | offline |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| All ranks | 11.9% | 12.3% |
|
|
116
|
+
| Undergraduate, Master, PhD | 14.3% | 14.7% |
|
|
117
|
+
| Postdoc | 13.4% | 14.0% |
|
|
118
|
+
| Junior or Staff | 12.6% | 12.2% |
|
|
119
|
+
| Senior | 7.9% | 8.5% |
|
|
120
|
+
|
|
121
|
+
The female share per year (Fig. 21, 45 years with at least 100 authors) differs by −0.04
|
|
122
|
+
points on average and by at most 1.2 points.
|
|
123
|
+
|
|
124
|
+
These are the genders used to reproduce the paper's results:
|
|
125
|
+
the package gives the same gender to every one of the 29,979 authors.
|
|
126
|
+
|
|
127
|
+
**Speed** (one core, laptop): the dictionary loads in about 1 s; lookups run at about
|
|
128
|
+
140,000 names/s.
|
|
129
|
+
|
|
130
|
+
The genders are estimates from names, not self-reported gender. Report them as such, and
|
|
131
|
+
cite WGND 2.0 (Raffo, J. (2021). WGND 2.0. Harvard Dataverse. doi:10.7910/DVN/MSEGSJ).
|
|
132
|
+
|
|
133
|
+
## The dictionary and this repository
|
|
134
|
+
|
|
135
|
+
The 91 MB WGND CSV is not stored in git. Instead:
|
|
136
|
+
|
|
137
|
+
1. **Built reproducibly.** `genderize-offline build wgnd.csv wgnd2_compact.tsv.gz` turns
|
|
138
|
+
WGND's `wgnd_2_0_name-gender-code.csv` into a compact table (one sorted line per name: average
|
|
139
|
+
share of women, number of countries, and the countries that differ from the average). The
|
|
140
|
+
same input gives the same bytes, so anyone can check the SHA-256.
|
|
141
|
+
2. **Published as a release asset.** The manual workflow [Dictionary](.github/workflows/dictionary.yml)
|
|
142
|
+
downloads WGND from Harvard Dataverse, builds the table and attaches it with its
|
|
143
|
+
`SHA256SUMS` to a GitHub release of its own, `wgnd-2.0-rN`. These tags don't start
|
|
144
|
+
with `v`, so they never publish to PyPI, and they aren't marked latest.
|
|
145
|
+
3. **Pinned in the code.** `dictionary.py` names the release and the SHA-256. On first use,
|
|
146
|
+
`fetch()` downloads the file to `~/.cache/genderize-offline/` (or `$GENDERIZE_OFFLINE_CACHE`)
|
|
147
|
+
and refuses it if the hash differs. If the release can't be reached, it builds the table
|
|
148
|
+
from Dataverse instead (`genderize-offline fetch --from-wgnd`).
|
|
149
|
+
|
|
150
|
+
To change the dictionary (a new WGND version or a new build rule), run the workflow with the
|
|
151
|
+
next tag, update `DICTIONARY_RELEASE` and `DICTIONARY_SHA256`, and release a new version.
|
|
152
|
+
Old releases keep their assets, so older package versions keep working.
|
|
153
|
+
|
|
154
|
+
## Development
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m pip install -e ".[dev]"
|
|
158
|
+
python -m pytest
|
|
159
|
+
ruff check . && ruff format --check .
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Tests use a synthetic dictionary and need no network. Releases are published to PyPI from
|
|
163
|
+
GitHub Actions when a `v*` tag is pushed; see [PUBLISHING.md](PUBLISHING.md) and
|
|
164
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
Code: MIT. Dictionary: derived from WGND 2.0, CC0 1.0.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# genderize-offline
|
|
2
|
+
|
|
3
|
+
[](https://github.com/restrepo/genderize-offline/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/genderize-offline/)
|
|
5
|
+
[](https://pypi.org/project/genderize-offline/)
|
|
6
|
+
|
|
7
|
+
Infer gender from first names and country **offline**: a free drop-in for
|
|
8
|
+
[genderize.io](https://genderize.io), with no API key, no quota and no network
|
|
9
|
+
after the first use. It uses the
|
|
10
|
+
[World Gender Name Dictionary 2.0](https://doi.org/10.7910/DVN/MSEGSJ) (WGND, Raffo 2021,
|
|
11
|
+
CC0), which gives the share of women and men for 4.9 million name–country pairs.
|
|
12
|
+
Standard library only.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install genderize-offline
|
|
18
|
+
genderize-offline fetch # once: downloads the dictionary (14 MB) into the cache
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from genderize_offline import guess, genderize
|
|
25
|
+
|
|
26
|
+
guess("Natalia Andrea", "CO") # ('female', 1.0, 58)
|
|
27
|
+
guess("Andrea", "Italy") # ('male', 1.0, 77) country names or ISO2 codes
|
|
28
|
+
guess("Andrea", "CO") # ('female', 0.96, 77)
|
|
29
|
+
genderize("Andrea", "IT") # {'count': 77, 'name': 'Andrea', 'gender': 'male', 'probability': 1.0, 'country_id': 'IT'}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`guess(names, country)` returns `(gender, probability, count)`: gender `male`, `female` or
|
|
33
|
+
`unknown`; probability 0.5–1 (0 for unknown); count, the number of countries that list the
|
|
34
|
+
name (genderize.io's count is people). `genderize()` answers in genderize.io's shape, with
|
|
35
|
+
`gender` `None` when unknown. As with genderize.io, set your own threshold: the benchmarks
|
|
36
|
+
below count a gender only at probability ≥ 0.9.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
genderize-offline name "María José" ES
|
|
40
|
+
genderize-offline csv authors.csv out.csv --name-col first_names --country-col country
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Method
|
|
44
|
+
|
|
45
|
+
- Names are lowercased and split on spaces, hyphens and dots; initials are dropped.
|
|
46
|
+
- The first given name found in the dictionary decides (genderize.io also uses the first word).
|
|
47
|
+
- The share of women is WGND's for that name in the given country; if the country is missing
|
|
48
|
+
or doesn't list the name, the average over every country that lists it is used.
|
|
49
|
+
- Accents are optional: `andres` finds `andrés`, `josé` finds `jose`.
|
|
50
|
+
|
|
51
|
+
## Benchmarks
|
|
52
|
+
|
|
53
|
+
Run with `python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json]` (threshold 0.9). The inputs
|
|
54
|
+
hold names of real people and are not in this repository; they are files of
|
|
55
|
+
[restrepo/inspireauth](https://github.com/restrepo/inspireauth).
|
|
56
|
+
|
|
57
|
+
**Hand-labelled names** (`data/test_gender.csv`: 3,965 names, 3,820 of them Colombian, with
|
|
58
|
+
their true gender and genderize.io's answer):
|
|
59
|
+
|
|
60
|
+
| | genderize.io | genderize-offline |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| Names given a gender (coverage) | 98.1% | 93.6% |
|
|
63
|
+
| Accuracy where a gender is given | 99.38% | 98.98% |
|
|
64
|
+
|
|
65
|
+
**INSPIRE-HEP authors with Latin American affiliations** (`data/inspire_LA.json`: 29,979
|
|
66
|
+
physicists, genders from genderize.io):
|
|
67
|
+
|
|
68
|
+
| genderize.io ↓ / offline → | male | female | unknown |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| male | 14,479 | 39 | 686 |
|
|
71
|
+
| female | 23 | 2,635 | 155 |
|
|
72
|
+
| unknown | 677 | 176 | 11,109 |
|
|
73
|
+
|
|
74
|
+
| | genderize.io | genderize-offline |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| Authors with a gender | 18,017 | 18,029 |
|
|
77
|
+
| Female share | 15.6% | 15.8% |
|
|
78
|
+
|
|
79
|
+
Where both give a gender (17,176 authors) they agree on **99.64%**.
|
|
80
|
+
|
|
81
|
+
**Effect on published results.** We recomputed the female fractions of *Statistical Analysis
|
|
82
|
+
of Scientific Metrics in High Energy, Cosmology, and Astroparticle Physics in Latin America*
|
|
83
|
+
(Morales-Alvarado, Quevedo, Ramos-Hamud, Restrepo) with offline genders. Every value moves
|
|
84
|
+
by less than its 95% margin:
|
|
85
|
+
|
|
86
|
+
| Female share by rank (Table 8) | genderize.io | offline |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| All ranks | 11.9% | 12.3% |
|
|
89
|
+
| Undergraduate, Master, PhD | 14.3% | 14.7% |
|
|
90
|
+
| Postdoc | 13.4% | 14.0% |
|
|
91
|
+
| Junior or Staff | 12.6% | 12.2% |
|
|
92
|
+
| Senior | 7.9% | 8.5% |
|
|
93
|
+
|
|
94
|
+
The female share per year (Fig. 21, 45 years with at least 100 authors) differs by −0.04
|
|
95
|
+
points on average and by at most 1.2 points.
|
|
96
|
+
|
|
97
|
+
These are the genders used to reproduce the paper's results:
|
|
98
|
+
the package gives the same gender to every one of the 29,979 authors.
|
|
99
|
+
|
|
100
|
+
**Speed** (one core, laptop): the dictionary loads in about 1 s; lookups run at about
|
|
101
|
+
140,000 names/s.
|
|
102
|
+
|
|
103
|
+
The genders are estimates from names, not self-reported gender. Report them as such, and
|
|
104
|
+
cite WGND 2.0 (Raffo, J. (2021). WGND 2.0. Harvard Dataverse. doi:10.7910/DVN/MSEGSJ).
|
|
105
|
+
|
|
106
|
+
## The dictionary and this repository
|
|
107
|
+
|
|
108
|
+
The 91 MB WGND CSV is not stored in git. Instead:
|
|
109
|
+
|
|
110
|
+
1. **Built reproducibly.** `genderize-offline build wgnd.csv wgnd2_compact.tsv.gz` turns
|
|
111
|
+
WGND's `wgnd_2_0_name-gender-code.csv` into a compact table (one sorted line per name: average
|
|
112
|
+
share of women, number of countries, and the countries that differ from the average). The
|
|
113
|
+
same input gives the same bytes, so anyone can check the SHA-256.
|
|
114
|
+
2. **Published as a release asset.** The manual workflow [Dictionary](.github/workflows/dictionary.yml)
|
|
115
|
+
downloads WGND from Harvard Dataverse, builds the table and attaches it with its
|
|
116
|
+
`SHA256SUMS` to a GitHub release of its own, `wgnd-2.0-rN`. These tags don't start
|
|
117
|
+
with `v`, so they never publish to PyPI, and they aren't marked latest.
|
|
118
|
+
3. **Pinned in the code.** `dictionary.py` names the release and the SHA-256. On first use,
|
|
119
|
+
`fetch()` downloads the file to `~/.cache/genderize-offline/` (or `$GENDERIZE_OFFLINE_CACHE`)
|
|
120
|
+
and refuses it if the hash differs. If the release can't be reached, it builds the table
|
|
121
|
+
from Dataverse instead (`genderize-offline fetch --from-wgnd`).
|
|
122
|
+
|
|
123
|
+
To change the dictionary (a new WGND version or a new build rule), run the workflow with the
|
|
124
|
+
next tag, update `DICTIONARY_RELEASE` and `DICTIONARY_SHA256`, and release a new version.
|
|
125
|
+
Old releases keep their assets, so older package versions keep working.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python -m pip install -e ".[dev]"
|
|
131
|
+
python -m pytest
|
|
132
|
+
ruff check . && ruff format --check .
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Tests use a synthetic dictionary and need no network. Releases are published to PyPI from
|
|
136
|
+
GitHub Actions when a `v*` tag is pushed; see [PUBLISHING.md](PUBLISHING.md) and
|
|
137
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Code: MIT. Dictionary: derived from WGND 2.0, CC0 1.0.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Benchmarks of genderize-offline against genderize.io (numbers in README.md).
|
|
2
|
+
|
|
3
|
+
Usage: python benchmarks/benchmark.py LABELLED.csv [AUTHORS.json] [--threshold 0.9]
|
|
4
|
+
|
|
5
|
+
LABELLED.csv: hand-labelled names with columns names, gender (the true one),
|
|
6
|
+
country_id, and genderize.io's answer in Gender and Gender Probability
|
|
7
|
+
(as `data/test_gender.csv` of restrepo/inspireauth).
|
|
8
|
+
AUTHORS.json: records with first_names, country, author_id and genderize.io's
|
|
9
|
+
gender and gender_probability (as `data/inspire_LA.json` of restrepo/inspireauth).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import csv
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from collections import Counter
|
|
17
|
+
|
|
18
|
+
from genderize_offline import core, dictionary, guess
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def scores(rows, thr):
|
|
22
|
+
known = [(t, g) for t, g, p in rows if g in ("male", "female") and p >= thr]
|
|
23
|
+
ok = sum(t == g for t, g in known)
|
|
24
|
+
return f"coverage {100 * len(known) / len(rows):.1f}%, accuracy {100 * ok / len(known):.2f}% ({len(known)} names)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv=None):
|
|
28
|
+
ap = argparse.ArgumentParser()
|
|
29
|
+
ap.add_argument("labelled")
|
|
30
|
+
ap.add_argument("authors", nargs="?")
|
|
31
|
+
ap.add_argument("--threshold", type=float, default=0.9)
|
|
32
|
+
a = ap.parse_args(argv)
|
|
33
|
+
thr = a.threshold
|
|
34
|
+
|
|
35
|
+
t0 = time.perf_counter()
|
|
36
|
+
core.use_table(dictionary.load())
|
|
37
|
+
print(f"load: {time.perf_counter() - t0:.1f} s, {len(core.table())} names and aliases")
|
|
38
|
+
|
|
39
|
+
with open(a.labelled, newline="", encoding="utf-8") as f:
|
|
40
|
+
rows = list(csv.DictReader(f))
|
|
41
|
+
gz = [(r["gender"], r["Gender"] or "unknown", float(r["Gender Probability"] or 0)) for r in rows]
|
|
42
|
+
t0 = time.perf_counter()
|
|
43
|
+
og = [(r["gender"], *guess(r["names"], r["country_id"])[:2]) for r in rows]
|
|
44
|
+
dt = time.perf_counter() - t0
|
|
45
|
+
print(f"\nhand-labelled names: {len(rows)}, threshold {thr} ({len(rows) / dt:,.0f} names/s)")
|
|
46
|
+
print(f" genderize.io {scores(gz, thr)}")
|
|
47
|
+
print(f" genderize-offline {scores(og, thr)}")
|
|
48
|
+
|
|
49
|
+
if not a.authors:
|
|
50
|
+
return 0
|
|
51
|
+
with open(a.authors, encoding="utf-8") as f:
|
|
52
|
+
records = json.load(f)
|
|
53
|
+
first = {}
|
|
54
|
+
for r in records:
|
|
55
|
+
first.setdefault(r["author_id"], r)
|
|
56
|
+
pairs = Counter()
|
|
57
|
+
for r in first.values():
|
|
58
|
+
g0 = r["gender"] if r["gender_probability"] >= thr else "unknown"
|
|
59
|
+
g, p, _ = guess(r.get("first_names", ""), r.get("country", ""))
|
|
60
|
+
pairs[g0, g if p >= thr else "unknown"] += 1
|
|
61
|
+
ks = ["male", "female", "unknown"]
|
|
62
|
+
print(f"\nauthors: {len(first)}; rows genderize.io, columns genderize-offline")
|
|
63
|
+
print(" " * 10 + "".join(f"{k:>10}" for k in ks))
|
|
64
|
+
for x in ks:
|
|
65
|
+
print(f"{x:>10}" + "".join(f"{pairs[x, y]:>10}" for y in ks))
|
|
66
|
+
both = sum(pairs[x, y] for x in ks[:2] for y in ks[:2])
|
|
67
|
+
agree = pairs["male", "male"] + pairs["female", "female"]
|
|
68
|
+
print(f"agreement where both give a gender: {100 * agree / both:.2f}% of {both}")
|
|
69
|
+
for name, i in [("genderize.io", 0), ("genderize-offline", 1)]:
|
|
70
|
+
fe = sum(v for k, v in pairs.items() if k[i] == "female")
|
|
71
|
+
ma = sum(v for k, v in pairs.items() if k[i] == "male")
|
|
72
|
+
print(f" {name:18s} {fe + ma} with a gender, female {100 * fe / (fe + ma):.1f}%")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24", "hatch-vcs>=0.4"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "genderize-offline"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Offline gender inference from first names and country, a free drop-in for genderize.io, using the World Gender Name Dictionary 2.0."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Diego Restrepo", email = "restrepo@udea.edu.co" }]
|
|
14
|
+
keywords = ["gender", "genderize", "names", "first names", "WGND", "bibliometrics", "offline"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=8", "pytest-cov>=5", "ruff==0.17.3", "build>=1.2", "twine>=5"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
genderize-offline = "genderize_offline.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/restrepo/genderize-offline"
|
|
33
|
+
Repository = "https://github.com/restrepo/genderize-offline"
|
|
34
|
+
Issues = "https://github.com/restrepo/genderize-offline/issues"
|
|
35
|
+
Changelog = "https://github.com/restrepo/genderize-offline/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
# Version from the git tag (v0.1.0 -> 0.1.0); commits after a tag get 0.1.1.devN
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
source = "vcs"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version.raw-options]
|
|
42
|
+
local_scheme = "no-local-version" # PyPI and TestPyPI reject local versions (+g1234abc)
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src/", "tests/", "benchmarks/", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/genderize_offline"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
addopts = "-ra --strict-markers"
|
|
53
|
+
|
|
54
|
+
[tool.coverage.run]
|
|
55
|
+
source = ["genderize_offline"]
|
|
56
|
+
branch = true
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
src = ["src", "tests"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Offline gender inference from first names and country, a free drop-in for genderize.io (WGND 2.0)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .core import country_code, genderize, guess, p_female, tokens, use_table
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("genderize-offline")
|
|
9
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__", "country_code", "genderize", "guess", "p_female", "tokens", "use_table"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Command line entry point: `genderize-offline`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__, dictionary
|
|
9
|
+
from .core import guess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None):
|
|
13
|
+
ap = argparse.ArgumentParser(
|
|
14
|
+
prog="genderize-offline",
|
|
15
|
+
description="Offline gender inference from first names and country, a free drop-in for genderize.io, "
|
|
16
|
+
"using the World Gender Name Dictionary 2.0.",
|
|
17
|
+
)
|
|
18
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
19
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
p = sub.add_parser("name", help="gender of one name, as JSON")
|
|
21
|
+
p.add_argument("names")
|
|
22
|
+
p.add_argument("country", nargs="?", default="", help="ISO2 code or country name")
|
|
23
|
+
p = sub.add_parser("csv", help="add gender columns to a CSV")
|
|
24
|
+
p.add_argument("src")
|
|
25
|
+
p.add_argument("dst")
|
|
26
|
+
p.add_argument("--name-col", default="name")
|
|
27
|
+
p.add_argument("--country-col", default="country_id")
|
|
28
|
+
p.add_argument("--prefix", default="", help="prefix of the added columns gender, probability, count")
|
|
29
|
+
p = sub.add_parser("fetch", help="download the dictionary into the cache")
|
|
30
|
+
p.add_argument("--from-wgnd", action="store_true", help="build it from the original WGND CSV instead")
|
|
31
|
+
p = sub.add_parser("build", help="build the compact dictionary from WGND's name-gender-code CSV")
|
|
32
|
+
p.add_argument("src")
|
|
33
|
+
p.add_argument("dst")
|
|
34
|
+
a = ap.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
if a.cmd == "name":
|
|
37
|
+
g, p_, n = guess(a.names, a.country)
|
|
38
|
+
print(json.dumps({"name": a.names, "gender": g, "probability": p_, "count": n}, ensure_ascii=False))
|
|
39
|
+
elif a.cmd == "csv":
|
|
40
|
+
with open(a.src, newline="", encoding="utf-8") as f:
|
|
41
|
+
rows = list(csv.DictReader(f))
|
|
42
|
+
cols = [f"{a.prefix}gender", f"{a.prefix}probability", f"{a.prefix}count"]
|
|
43
|
+
with open(a.dst, "w", newline="", encoding="utf-8") as f:
|
|
44
|
+
w = csv.DictWriter(f, list(rows[0]) + cols if rows else cols)
|
|
45
|
+
w.writeheader()
|
|
46
|
+
for r in rows:
|
|
47
|
+
r.update(zip(cols, guess(r.get(a.name_col, ""), r.get(a.country_col, "")), strict=True))
|
|
48
|
+
w.writerow(r)
|
|
49
|
+
elif a.cmd == "fetch":
|
|
50
|
+
print(dictionary.fetch(from_wgnd=a.from_wgnd))
|
|
51
|
+
elif a.cmd == "build":
|
|
52
|
+
n = dictionary.build(a.src, a.dst)
|
|
53
|
+
print(f"{a.dst}: {n} names, sha256 {dictionary.sha256(a.dst)}", file=sys.stderr)
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Gender of a first name in a country, like genderize.io."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
from . import dictionary
|
|
8
|
+
|
|
9
|
+
COUNTRIES = json.loads(files(__package__).joinpath("country_iso2.json").read_text(encoding="utf-8"))
|
|
10
|
+
|
|
11
|
+
_table = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def table():
|
|
15
|
+
"""The dictionary, loaded on first use."""
|
|
16
|
+
global _table
|
|
17
|
+
if _table is None:
|
|
18
|
+
_table = dictionary.load()
|
|
19
|
+
return _table
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def use_table(t):
|
|
23
|
+
"""Use another dictionary (`dictionary.load(path)` or a dict of the same shape), e.g. in tests."""
|
|
24
|
+
global _table
|
|
25
|
+
_table = t
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def country_code(country):
|
|
29
|
+
"""ISO2 code for an ISO2 code or a country name (INSPIRE/English spelling); '' if unknown."""
|
|
30
|
+
if not country:
|
|
31
|
+
return ""
|
|
32
|
+
c = country.strip()
|
|
33
|
+
if len(c) == 2 and c.isalpha():
|
|
34
|
+
return c.upper()
|
|
35
|
+
return COUNTRIES.get(c) or COUNTRIES.get(c.lower(), "")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tokens(names):
|
|
39
|
+
"""Lowercase given names, without initials, punctuation or hyphens."""
|
|
40
|
+
names = re.sub(r'[|†•()"]', " ", names or "").replace("-", " ").replace(".", " ")
|
|
41
|
+
return [t.lower() for t in names.split() if len(t) > 1]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def p_female(name, country=""):
|
|
45
|
+
"""(share of women, countries listing the name) for one name, or None if it is not in the dictionary."""
|
|
46
|
+
t = table()
|
|
47
|
+
e = t.get(name) or t.get(dictionary.strip_accents(name))
|
|
48
|
+
if not e:
|
|
49
|
+
return None
|
|
50
|
+
world, n, extra = e
|
|
51
|
+
code = country_code(country)
|
|
52
|
+
if code and code + ":" in extra:
|
|
53
|
+
for item in extra.split(","):
|
|
54
|
+
c, p = item.split(":")
|
|
55
|
+
if c == code:
|
|
56
|
+
return float(p), n
|
|
57
|
+
return world, n
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def guess(names, country=""):
|
|
61
|
+
"""(gender, probability, count) for given names and a country (ISO2 or name).
|
|
62
|
+
|
|
63
|
+
The first given name found in the dictionary decides. gender is 'male',
|
|
64
|
+
'female' or 'unknown'; probability is 0.5-1 (0 for unknown); count is the
|
|
65
|
+
number of countries listing the name.
|
|
66
|
+
"""
|
|
67
|
+
for tok in tokens(names):
|
|
68
|
+
r = p_female(tok, country)
|
|
69
|
+
if r is None:
|
|
70
|
+
continue
|
|
71
|
+
pf, n = r
|
|
72
|
+
if pf == 0.5:
|
|
73
|
+
return "unknown", 0.0, n
|
|
74
|
+
return ("female", round(pf, 2), n) if pf > 0.5 else ("male", round(1 - pf, 2), n)
|
|
75
|
+
return "unknown", 0.0, 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def genderize(names, country_id=None):
|
|
79
|
+
"""Answer shaped like genderize.io's: {name, gender, probability, count, country_id}.
|
|
80
|
+
|
|
81
|
+
gender is None when unknown, as in genderize.io.
|
|
82
|
+
"""
|
|
83
|
+
g, p, n = guess(names, country_id or "")
|
|
84
|
+
out = {"count": n, "name": names, "gender": None if g == "unknown" else g, "probability": p}
|
|
85
|
+
if country_id:
|
|
86
|
+
out["country_id"] = country_id
|
|
87
|
+
return out
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Venezuela": "VE",
|
|
3
|
+
"Chile": "CL",
|
|
4
|
+
"Rwanda": "RW",
|
|
5
|
+
"South Africa": "ZA",
|
|
6
|
+
"Spain": "ES",
|
|
7
|
+
"United States": "US",
|
|
8
|
+
"France": "FR",
|
|
9
|
+
"Switzerland": "CH",
|
|
10
|
+
"Italy": "IT",
|
|
11
|
+
"Germany": "DE",
|
|
12
|
+
"Peru": "PE",
|
|
13
|
+
"Argentina": "AR",
|
|
14
|
+
"Uruguay": "UY",
|
|
15
|
+
"Brazil": "BR",
|
|
16
|
+
"Ecuador": "EC",
|
|
17
|
+
"United Kingdom": "GB",
|
|
18
|
+
"Colombia": "CO",
|
|
19
|
+
"Israel": "IL",
|
|
20
|
+
"Mexico": "MX",
|
|
21
|
+
"Netherlands": "NL",
|
|
22
|
+
"Canada": "CA",
|
|
23
|
+
"Sweden": "SE",
|
|
24
|
+
"Iran, Islamic Republic of": "IR",
|
|
25
|
+
"India": "IN",
|
|
26
|
+
"Belarus": "BY",
|
|
27
|
+
"Russian Federation": "RU",
|
|
28
|
+
"Hungary": "HU",
|
|
29
|
+
"Slovakia": "SK",
|
|
30
|
+
"Greece": "GR",
|
|
31
|
+
"Cuba": "CU",
|
|
32
|
+
"Australia": "AU",
|
|
33
|
+
"Kuwait": "KW",
|
|
34
|
+
"Slovenia": "SI",
|
|
35
|
+
"Finland": "FI",
|
|
36
|
+
"Japan": "JP",
|
|
37
|
+
"Ukraine": "UA",
|
|
38
|
+
"Serbia and Montenegro": "RS",
|
|
39
|
+
"Turkey": "TR",
|
|
40
|
+
"Portugal": "PT",
|
|
41
|
+
"Armenia": "AM",
|
|
42
|
+
"Korea, Republic of": "KR",
|
|
43
|
+
"China": "CN",
|
|
44
|
+
"Belgium": "BE",
|
|
45
|
+
"Poland": "PL",
|
|
46
|
+
"Denmark": "DK",
|
|
47
|
+
"Czechia": "CZ",
|
|
48
|
+
"Romania": "RO",
|
|
49
|
+
"Croatia": "HR",
|
|
50
|
+
"Dominican Republic": "DO",
|
|
51
|
+
"Guatemala": "GT",
|
|
52
|
+
"Paraguay": "PY",
|
|
53
|
+
"Panama": "PA",
|
|
54
|
+
"Honduras": "HN",
|
|
55
|
+
"Philippines": "PH",
|
|
56
|
+
"El Salvador": "SV",
|
|
57
|
+
"Pakistan": "PK",
|
|
58
|
+
"Oman": "OM",
|
|
59
|
+
"Costa Rica": "CR",
|
|
60
|
+
"New Zealand": "NZ",
|
|
61
|
+
"Georgia": "GE",
|
|
62
|
+
"Bolivia": "BO",
|
|
63
|
+
"Malta": "MT",
|
|
64
|
+
"Austria": "AT",
|
|
65
|
+
"Norway": "NO",
|
|
66
|
+
"Egypt": "EG",
|
|
67
|
+
"Taiwan": "TW",
|
|
68
|
+
"Puerto Rico": "PR",
|
|
69
|
+
"United Arab Emirates": "AE",
|
|
70
|
+
"Jamaica": "JM",
|
|
71
|
+
"Lithuania": "LT",
|
|
72
|
+
"Indonesia": "ID",
|
|
73
|
+
"Luxembourg": "LU",
|
|
74
|
+
"Estonia": "EE",
|
|
75
|
+
"Ethiopia": "ET",
|
|
76
|
+
"Thailand": "TH",
|
|
77
|
+
"Cyprus": "CY",
|
|
78
|
+
"Bulgaria": "BG",
|
|
79
|
+
"Iceland": "IS",
|
|
80
|
+
"North Macedonia": "MK",
|
|
81
|
+
"Ireland": "IE",
|
|
82
|
+
"Lebanon": "LB",
|
|
83
|
+
"Vietnam": "VN",
|
|
84
|
+
"Bangladesh": "BD",
|
|
85
|
+
"Kazakhstan": "KZ",
|
|
86
|
+
"Morocco": "MA",
|
|
87
|
+
"Uzbekistan": "UZ",
|
|
88
|
+
"Benin": "BJ",
|
|
89
|
+
"Iraq": "IQ",
|
|
90
|
+
"Azerbaijan": "AZ",
|
|
91
|
+
"Nigeria": "NG",
|
|
92
|
+
"Jordan": "JO",
|
|
93
|
+
"Ghana": "GH",
|
|
94
|
+
"Algeria": "DZ",
|
|
95
|
+
"Singapore": "SG",
|
|
96
|
+
"Niger": "NE",
|
|
97
|
+
"Malaysia": "MY",
|
|
98
|
+
"Latvia": "LV",
|
|
99
|
+
"Saudi Arabia": "SA",
|
|
100
|
+
"Madagascar": "MG",
|
|
101
|
+
"Uganda": "UG",
|
|
102
|
+
"colombia": "CO",
|
|
103
|
+
"brazil": "BR",
|
|
104
|
+
"venezuela": "VE",
|
|
105
|
+
"mexico": "MX",
|
|
106
|
+
"cuba": "CU",
|
|
107
|
+
"chile": "CL",
|
|
108
|
+
"peru": "PE",
|
|
109
|
+
"argentina": "AR"
|
|
110
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The name dictionary: a compact table derived from WGND 2.0.
|
|
2
|
+
|
|
3
|
+
WGND 2.0 (World Gender Name Dictionary, Raffo 2021, Harvard Dataverse,
|
|
4
|
+
doi:10.7910/DVN/MSEGSJ, CC0) gives, for each (name, ISO2 country), the share
|
|
5
|
+
of women and men. The compact table keeps one line per name::
|
|
6
|
+
|
|
7
|
+
name <TAB> p_female averaged over its countries <TAB> number of countries <TAB> CC:p,CC:p,...
|
|
8
|
+
|
|
9
|
+
where only countries whose share differs from the average are listed. It is
|
|
10
|
+
built with `build()` from the original CSV and published as a GitHub release
|
|
11
|
+
asset of this repository (tag `wgnd-2.0-rN`); `load()` downloads it once,
|
|
12
|
+
checks its SHA-256 and caches it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import bisect
|
|
16
|
+
import csv
|
|
17
|
+
import gzip
|
|
18
|
+
import hashlib
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import tempfile
|
|
22
|
+
import unicodedata
|
|
23
|
+
import urllib.request
|
|
24
|
+
from collections import defaultdict
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
WGND_URL = "https://dataverse.harvard.edu/api/access/datafile/4750348?format=original"
|
|
28
|
+
|
|
29
|
+
DICTIONARY_RELEASE = "wgnd-2.0-r1"
|
|
30
|
+
DICTIONARY_FILE = "wgnd2_compact.tsv.gz"
|
|
31
|
+
DICTIONARY_URL = (
|
|
32
|
+
f"https://github.com/restrepo/genderize-offline/releases/download/{DICTIONARY_RELEASE}/{DICTIONARY_FILE}"
|
|
33
|
+
)
|
|
34
|
+
DICTIONARY_SHA256 = "c9020732db57856a45f5bdb469b971a5ad3febf684eef0e6256757b61a07ffaf"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cache_dir():
|
|
38
|
+
"""`$GENDERIZE_OFFLINE_CACHE`, else `~/.cache/genderize-offline`."""
|
|
39
|
+
return Path(os.environ.get("GENDERIZE_OFFLINE_CACHE") or Path.home() / ".cache" / "genderize-offline")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_accents(s):
|
|
43
|
+
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sha256(path):
|
|
47
|
+
h = hashlib.sha256()
|
|
48
|
+
with open(path, "rb") as f:
|
|
49
|
+
for block in iter(lambda: f.read(1 << 20), b""):
|
|
50
|
+
h.update(block)
|
|
51
|
+
return h.hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build(src_csv, dst_gz):
|
|
55
|
+
"""Write the compact table from WGND's `wgnd_2_0_name-gender-code.csv`. Returns the number of names."""
|
|
56
|
+
raw = defaultdict(lambda: defaultdict(lambda: [0.0, 0.0]))
|
|
57
|
+
with open(src_csv, newline="", encoding="utf-8") as f:
|
|
58
|
+
for r in csv.DictReader(f):
|
|
59
|
+
if r["gender"] in ("F", "M"):
|
|
60
|
+
raw[r["name"].strip().lower()][r["code"]][r["gender"] == "M"] += float(r["wgt"] or 0)
|
|
61
|
+
rows = {}
|
|
62
|
+
for name, by_code in raw.items():
|
|
63
|
+
by = {c: fm[0] / (fm[0] + fm[1]) for c, fm in by_code.items() if fm[0] + fm[1] > 0}
|
|
64
|
+
if not by or not name or "\t" in name or "\n" in name:
|
|
65
|
+
continue
|
|
66
|
+
world = round(sum(by.values()) / len(by), 6)
|
|
67
|
+
extra = ",".join(f"{c}:{round(p, 6):g}" for c, p in sorted(by.items()) if c and round(p, 6) != world)
|
|
68
|
+
rows[name] = f"{world:g}\t{sum(1 for c in by if c)}\t{extra}"
|
|
69
|
+
# an accent-free spelling not in WGND gets the entry of the first accented one (andres -> andrés)
|
|
70
|
+
for name in sorted(rows):
|
|
71
|
+
rows.setdefault(strip_accents(name), rows[name])
|
|
72
|
+
lines = sorted(f"{name}\t{v}" for name, v in rows.items())
|
|
73
|
+
# mtime=0 so the same input gives the same bytes (and SHA-256)
|
|
74
|
+
with open(dst_gz, "wb") as raw_out, gzip.GzipFile(filename="", fileobj=raw_out, mode="wb", mtime=0) as gz:
|
|
75
|
+
gz.write(("\n".join(lines) + "\n").encode("utf-8"))
|
|
76
|
+
return len(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def download(url, dst):
|
|
80
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
with tempfile.NamedTemporaryFile(dir=dst.parent, delete=False) as tmp, urllib.request.urlopen(url) as r:
|
|
82
|
+
shutil.copyfileobj(r, tmp)
|
|
83
|
+
os.replace(tmp.name, dst)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fetch(from_wgnd=False):
|
|
87
|
+
"""Make sure the compact table is in the cache and return its path.
|
|
88
|
+
|
|
89
|
+
By default it is downloaded from this repository's release and checked
|
|
90
|
+
against `DICTIONARY_SHA256`; with `from_wgnd=True` (or if the release is
|
|
91
|
+
unreachable) it is built from the original WGND CSV on Harvard Dataverse.
|
|
92
|
+
"""
|
|
93
|
+
path = cache_dir() / DICTIONARY_FILE
|
|
94
|
+
if path.exists() and (from_wgnd or sha256(path) == DICTIONARY_SHA256):
|
|
95
|
+
return path
|
|
96
|
+
if not from_wgnd:
|
|
97
|
+
try:
|
|
98
|
+
download(DICTIONARY_URL, path)
|
|
99
|
+
if sha256(path) == DICTIONARY_SHA256:
|
|
100
|
+
return path
|
|
101
|
+
path.unlink()
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
src = cache_dir() / "wgnd_2_0_name-gender-code.csv"
|
|
105
|
+
if not src.exists():
|
|
106
|
+
download(WGND_URL, src)
|
|
107
|
+
build(src, path)
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Table:
|
|
112
|
+
"""The compact table in memory: sorted lines, looked up by bisection."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, lines):
|
|
115
|
+
self.lines = lines
|
|
116
|
+
|
|
117
|
+
def __len__(self):
|
|
118
|
+
return len(self.lines)
|
|
119
|
+
|
|
120
|
+
def get(self, name, default=None):
|
|
121
|
+
key = name + "\t"
|
|
122
|
+
i = bisect.bisect_left(self.lines, key)
|
|
123
|
+
if i < len(self.lines) and self.lines[i].startswith(key):
|
|
124
|
+
_, world, n, extra = self.lines[i].split("\t")
|
|
125
|
+
return float(world), int(n), extra
|
|
126
|
+
return default
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load(path=None):
|
|
130
|
+
"""The dictionary as a `Table`: `.get(name)` → (p_female world, n_countries, "CC:p,...") or None."""
|
|
131
|
+
path = Path(path) if path else fetch()
|
|
132
|
+
with open(path, "rb") as f:
|
|
133
|
+
text = gzip.decompress(f.read()).decode("utf-8")
|
|
134
|
+
return Table(text.splitlines())
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from genderize_offline import core, dictionary
|
|
4
|
+
|
|
5
|
+
# A synthetic WGND extract: made-up spellings and shares, not real data.
|
|
6
|
+
WGND = """name,code,gender,wgt
|
|
7
|
+
zorana,AA,F,1
|
|
8
|
+
zorana,BB,F,.8
|
|
9
|
+
zorana,BB,M,.2
|
|
10
|
+
tomislavo,AA,M,1
|
|
11
|
+
kelandre,AA,F,.6
|
|
12
|
+
kelandre,AA,M,.4
|
|
13
|
+
kelandre,BB,M,.9
|
|
14
|
+
kelandre,BB,F,.1
|
|
15
|
+
mirén,AA,F,1
|
|
16
|
+
ambo,AA,F,.5
|
|
17
|
+
ambo,AA,M,.5
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(scope="session")
|
|
22
|
+
def compact(tmp_path_factory):
|
|
23
|
+
d = tmp_path_factory.mktemp("wgnd")
|
|
24
|
+
src = d / "wgnd.csv"
|
|
25
|
+
src.write_text(WGND, encoding="utf-8")
|
|
26
|
+
dst = d / "compact.tsv.gz"
|
|
27
|
+
dictionary.build(src, dst)
|
|
28
|
+
return dst
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture(autouse=True)
|
|
32
|
+
def table(compact):
|
|
33
|
+
core.use_table(dictionary.load(compact))
|
|
34
|
+
yield
|
|
35
|
+
core.use_table(None)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from genderize_offline import __version__
|
|
7
|
+
from genderize_offline.cli import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_version(capsys):
|
|
11
|
+
with pytest.raises(SystemExit) as e:
|
|
12
|
+
main(["--version"])
|
|
13
|
+
assert e.value.code == 0
|
|
14
|
+
assert __version__ in capsys.readouterr().out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_name(capsys):
|
|
18
|
+
assert main(["name", "Kelandre", "BB"]) == 0
|
|
19
|
+
assert json.loads(capsys.readouterr().out) == {"name": "Kelandre", "gender": "male", "probability": 0.9, "count": 2}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_csv(tmp_path):
|
|
23
|
+
src = tmp_path / "in.csv"
|
|
24
|
+
src.write_text("name,country_id\nZorana,BB\nXyzzy,AA\n", encoding="utf-8")
|
|
25
|
+
dst = tmp_path / "out.csv"
|
|
26
|
+
assert main(["csv", str(src), str(dst)]) == 0
|
|
27
|
+
with open(dst, newline="", encoding="utf-8") as f:
|
|
28
|
+
rows = list(csv.DictReader(f))
|
|
29
|
+
assert [(r["gender"], r["probability"]) for r in rows] == [("female", "0.8"), ("unknown", "0.0")]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_build(tmp_path, capsys):
|
|
33
|
+
from conftest import WGND
|
|
34
|
+
|
|
35
|
+
src = tmp_path / "w.csv"
|
|
36
|
+
src.write_text(WGND, encoding="utf-8")
|
|
37
|
+
assert main(["build", str(src), str(tmp_path / "c.tsv.gz")]) == 0
|
|
38
|
+
assert "sha256" in capsys.readouterr().err
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
|
|
3
|
+
from genderize_offline import country_code, dictionary, genderize, guess, tokens
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_build_is_reproducible(compact, tmp_path):
|
|
7
|
+
src = tmp_path / "w.csv"
|
|
8
|
+
from conftest import WGND
|
|
9
|
+
|
|
10
|
+
src.write_text(WGND, encoding="utf-8")
|
|
11
|
+
again = tmp_path / "again.tsv.gz"
|
|
12
|
+
dictionary.build(src, again)
|
|
13
|
+
assert dictionary.sha256(again) == dictionary.sha256(compact)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_compact_lines(compact):
|
|
17
|
+
lines = gzip.decompress(compact.read_bytes()).decode().splitlines()
|
|
18
|
+
assert lines == sorted(lines)
|
|
19
|
+
assert "zorana\t0.9\t2\tAA:1,BB:0.8" in lines
|
|
20
|
+
assert "miren\t1\t1\t" in lines # accent-free alias
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_country_first_then_average():
|
|
24
|
+
assert guess("Zorana", "BB") == ("female", 0.8, 2)
|
|
25
|
+
assert guess("Zorana", "CC") == ("female", 0.9, 2)
|
|
26
|
+
assert guess("Zorana") == ("female", 0.9, 2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_country_changes_gender():
|
|
30
|
+
assert guess("Kelandre", "AA") == ("female", 0.6, 2)
|
|
31
|
+
assert guess("Kelandre", "BB") == ("male", 0.9, 2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_first_known_name_decides():
|
|
35
|
+
assert guess("Q. Xyzzy Tomislavo Zorana", "AA") == ("male", 1.0, 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_unknown_and_unisex():
|
|
39
|
+
assert guess("Xyzzy", "AA") == ("unknown", 0.0, 0)
|
|
40
|
+
assert guess("", "AA") == ("unknown", 0.0, 0)
|
|
41
|
+
assert guess("Ambo", "AA") == ("unknown", 0.0, 1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_accents_both_ways():
|
|
45
|
+
assert guess("Mirén")[0] == "female"
|
|
46
|
+
assert guess("Miren")[0] == "female"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_tokens_and_country_code():
|
|
50
|
+
assert tokens("Jean-Pierre A. B.") == ["jean", "pierre"]
|
|
51
|
+
assert country_code("co") == "CO"
|
|
52
|
+
assert country_code("Colombia") == "CO"
|
|
53
|
+
assert country_code("Russian Federation") == "RU"
|
|
54
|
+
assert country_code("Atlantis") == ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_genderize_shape():
|
|
58
|
+
assert genderize("Zorana", "BB") == {
|
|
59
|
+
"count": 2,
|
|
60
|
+
"name": "Zorana",
|
|
61
|
+
"gender": "female",
|
|
62
|
+
"probability": 0.8,
|
|
63
|
+
"country_id": "BB",
|
|
64
|
+
}
|
|
65
|
+
assert genderize("Xyzzy")["gender"] is None
|