genderfluid-tiny 1.0.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.
- genderfluid/__init__.py +78 -0
- genderfluid/__main__.py +6 -0
- genderfluid/calibration.py +110 -0
- genderfluid/classifier.py +181 -0
- genderfluid/cli.py +270 -0
- genderfluid/features.py +83 -0
- genderfluid/inference.py +213 -0
- genderfluid/model_io.py +166 -0
- genderfluid/models/genderfluid-tiny.bin +0 -0
- genderfluid/preprocessing.py +57 -0
- genderfluid_tiny-1.0.0.dist-info/METADATA +369 -0
- genderfluid_tiny-1.0.0.dist-info/RECORD +15 -0
- genderfluid_tiny-1.0.0.dist-info/WHEEL +5 -0
- genderfluid_tiny-1.0.0.dist-info/entry_points.txt +2 -0
- genderfluid_tiny-1.0.0.dist-info/top_level.txt +1 -0
genderfluid/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""genderfluid-tiny: Ultra-tiny name-gender association classifier."""
|
|
2
|
+
|
|
3
|
+
from genderfluid.inference import (
|
|
4
|
+
predict_name,
|
|
5
|
+
predict_names,
|
|
6
|
+
GenderfluidModel,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def classify_name(name: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Return just the classification string.
|
|
13
|
+
|
|
14
|
+
Returns one of: "girl-associated", "boy-associated", "uncertain"
|
|
15
|
+
|
|
16
|
+
Example::
|
|
17
|
+
|
|
18
|
+
from genderfluid import classify_name
|
|
19
|
+
|
|
20
|
+
classify_name("Emma") # "girl-associated"
|
|
21
|
+
classify_name("James") # "boy-associated"
|
|
22
|
+
classify_name("Alex") # "uncertain"
|
|
23
|
+
"""
|
|
24
|
+
return predict_name(name)["classification"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_girl_name(name: str) -> bool:
|
|
28
|
+
"""
|
|
29
|
+
Return True if the name is classified as girl-associated.
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
from genderfluid import is_girl_name
|
|
34
|
+
|
|
35
|
+
if is_girl_name("Emma"):
|
|
36
|
+
print("girl name")
|
|
37
|
+
"""
|
|
38
|
+
return predict_name(name)["classification"] == "girl-associated"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_boy_name(name: str) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Return True if the name is classified as boy-associated.
|
|
44
|
+
|
|
45
|
+
Example::
|
|
46
|
+
|
|
47
|
+
from genderfluid import is_boy_name
|
|
48
|
+
|
|
49
|
+
if is_boy_name("James"):
|
|
50
|
+
print("boy name")
|
|
51
|
+
"""
|
|
52
|
+
return predict_name(name)["classification"] == "boy-associated"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def name_probability(name: str) -> float:
|
|
56
|
+
"""
|
|
57
|
+
Return the girl-associated probability as a float between 0 and 1.
|
|
58
|
+
|
|
59
|
+
Example::
|
|
60
|
+
|
|
61
|
+
from genderfluid import name_probability
|
|
62
|
+
|
|
63
|
+
p = name_probability("Emma") # 0.97
|
|
64
|
+
p = name_probability("Alex") # 0.27
|
|
65
|
+
"""
|
|
66
|
+
return predict_name(name)["girl_associated_probability"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__version__ = "1.0.0"
|
|
70
|
+
__all__ = [
|
|
71
|
+
"predict_name",
|
|
72
|
+
"predict_names",
|
|
73
|
+
"GenderfluidModel",
|
|
74
|
+
"classify_name",
|
|
75
|
+
"is_girl_name",
|
|
76
|
+
"is_boy_name",
|
|
77
|
+
"name_probability",
|
|
78
|
+
]
|
genderfluid/__main__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Probability calibration analysis."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def calibration_error(
|
|
7
|
+
y_true: np.ndarray,
|
|
8
|
+
y_proba: np.ndarray,
|
|
9
|
+
n_bins: int = 10,
|
|
10
|
+
) -> float:
|
|
11
|
+
"""
|
|
12
|
+
Compute Expected Calibration Error (ECE).
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
y_true: True class labels
|
|
16
|
+
y_proba: Predicted probabilities
|
|
17
|
+
n_bins: Number of bins for calibration
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Expected calibration error
|
|
21
|
+
"""
|
|
22
|
+
bin_boundaries = np.linspace(0, 1, n_bins + 1)
|
|
23
|
+
ece = 0.0
|
|
24
|
+
|
|
25
|
+
for i in range(n_bins):
|
|
26
|
+
lower = bin_boundaries[i]
|
|
27
|
+
upper = bin_boundaries[i + 1]
|
|
28
|
+
|
|
29
|
+
# Find samples in this probability bin
|
|
30
|
+
mask = (y_proba.max(axis=1) >= lower) & (y_proba.max(axis=1) < upper)
|
|
31
|
+
if mask.sum() == 0:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
bin_proba = y_proba[mask].max(axis=1)
|
|
35
|
+
bin_true = y_true[mask] == np.argmax(y_proba[mask], axis=1)
|
|
36
|
+
|
|
37
|
+
avg_confidence = bin_proba.mean()
|
|
38
|
+
avg_accuracy = bin_true.mean()
|
|
39
|
+
|
|
40
|
+
ece += mask.sum() / len(y_true) * abs(avg_accuracy - avg_confidence)
|
|
41
|
+
|
|
42
|
+
return float(ece)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reliability_data(
|
|
46
|
+
y_true: np.ndarray,
|
|
47
|
+
y_proba: np.ndarray,
|
|
48
|
+
n_bins: int = 10,
|
|
49
|
+
) -> list[dict]:
|
|
50
|
+
"""
|
|
51
|
+
Compute reliability diagram data.
|
|
52
|
+
|
|
53
|
+
Returns list of dicts with bin_lower, bin_upper, avg_confidence, avg_accuracy, count.
|
|
54
|
+
"""
|
|
55
|
+
bin_boundaries = np.linspace(0, 1, n_bins + 1)
|
|
56
|
+
result = []
|
|
57
|
+
|
|
58
|
+
for i in range(n_bins):
|
|
59
|
+
lower = bin_boundaries[i]
|
|
60
|
+
upper = bin_boundaries[i + 1]
|
|
61
|
+
|
|
62
|
+
mask = (y_proba.max(axis=1) >= lower) & (y_proba.max(axis=1) < upper)
|
|
63
|
+
if mask.sum() == 0:
|
|
64
|
+
result.append({
|
|
65
|
+
"bin_lower": float(lower),
|
|
66
|
+
"bin_upper": float(upper),
|
|
67
|
+
"avg_confidence": float((lower + upper) / 2),
|
|
68
|
+
"avg_accuracy": 0.0,
|
|
69
|
+
"count": 0,
|
|
70
|
+
})
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
bin_proba = y_proba[mask].max(axis=1)
|
|
74
|
+
bin_true = (y_true[mask] == np.argmax(y_proba[mask], axis=1)).astype(float)
|
|
75
|
+
|
|
76
|
+
result.append({
|
|
77
|
+
"bin_lower": float(lower),
|
|
78
|
+
"bin_upper": float(upper),
|
|
79
|
+
"avg_confidence": float(bin_proba.mean()),
|
|
80
|
+
"avg_accuracy": float(bin_true.mean()),
|
|
81
|
+
"count": int(mask.sum()),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def confusion_matrix(
|
|
88
|
+
y_true: np.ndarray,
|
|
89
|
+
y_pred: np.ndarray,
|
|
90
|
+
labels: list[str],
|
|
91
|
+
) -> dict:
|
|
92
|
+
"""
|
|
93
|
+
Compute confusion matrix as a dict.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
{label: {predicted_label: count}}
|
|
97
|
+
"""
|
|
98
|
+
n_labels = len(labels)
|
|
99
|
+
matrix = np.zeros((n_labels, n_labels), dtype=int)
|
|
100
|
+
|
|
101
|
+
for true, pred in zip(y_true, y_pred):
|
|
102
|
+
matrix[true, pred] += 1
|
|
103
|
+
|
|
104
|
+
result = {}
|
|
105
|
+
for i, true_label in enumerate(labels):
|
|
106
|
+
result[true_label] = {}
|
|
107
|
+
for j, pred_label in enumerate(labels):
|
|
108
|
+
result[true_label][pred_label] = int(matrix[i, j])
|
|
109
|
+
|
|
110
|
+
return result
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Logistic regression classifier with lightweight probability calibration."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from sklearn.linear_model import LogisticRegression
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
LABELS = ["girl-associated", "boy-associated", "uncertain"]
|
|
9
|
+
LABEL_TO_IDX = {label: i for i, label in enumerate(LABELS)}
|
|
10
|
+
NUM_CLASSES = 3
|
|
11
|
+
MIN_EXAMPLES_PER_CLASS = 5
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sigmoid(x):
|
|
15
|
+
return 1.0 / (1.0 + np.exp(-np.clip(x, -30, 30)))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NameClassifier:
|
|
19
|
+
"""
|
|
20
|
+
Logistic regression classifier for name-gender association.
|
|
21
|
+
|
|
22
|
+
Uses lightweight sigmoid (Platt) calibration instead of
|
|
23
|
+
CalibratedClassifierCV to keep memory usage minimal.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
C: float = 1.0,
|
|
29
|
+
max_iter: int = 1000,
|
|
30
|
+
min_confidence: float = 0.70,
|
|
31
|
+
):
|
|
32
|
+
self.C = C
|
|
33
|
+
self.max_iter = max_iter
|
|
34
|
+
self.min_confidence = min_confidence
|
|
35
|
+
self.model: Optional[LogisticRegression] = None
|
|
36
|
+
# Per-class sigmoid calibration parameters: A, B for sigmoid(A*raw + B)
|
|
37
|
+
self.calib_A: Optional[np.ndarray] = None
|
|
38
|
+
self.calib_B: Optional[np.ndarray] = None
|
|
39
|
+
|
|
40
|
+
def train(
|
|
41
|
+
self,
|
|
42
|
+
X: np.ndarray,
|
|
43
|
+
y: np.ndarray,
|
|
44
|
+
sample_weight: Optional[np.ndarray] = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Train the classifier with all 3 classes guaranteed."""
|
|
47
|
+
rng = np.random.RandomState(42)
|
|
48
|
+
unique_classes = set(y.tolist())
|
|
49
|
+
missing = set(range(NUM_CLASSES)) - unique_classes
|
|
50
|
+
|
|
51
|
+
X_train = X
|
|
52
|
+
y_train = y.copy()
|
|
53
|
+
sw_train = sample_weight.copy() if sample_weight is not None else None
|
|
54
|
+
|
|
55
|
+
for cls in sorted(missing):
|
|
56
|
+
n_pad = MIN_EXAMPLES_PER_CLASS
|
|
57
|
+
X_pad = rng.randn(n_pad, X.shape[1]).astype(np.float32) * 0.001
|
|
58
|
+
y_pad = np.full(n_pad, cls, dtype=int)
|
|
59
|
+
X_train = np.vstack([X_train, X_pad])
|
|
60
|
+
y_train = np.concatenate([y_train, y_pad])
|
|
61
|
+
if sw_train is not None:
|
|
62
|
+
w_pad = np.full(n_pad, 0.001)
|
|
63
|
+
sw_train = np.concatenate([sw_train, w_pad])
|
|
64
|
+
else:
|
|
65
|
+
sw_train = np.concatenate([
|
|
66
|
+
np.ones(len(y), dtype=np.float32),
|
|
67
|
+
np.full(n_pad, 0.001, dtype=np.float32),
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
self.model = LogisticRegression(
|
|
71
|
+
C=self.C,
|
|
72
|
+
max_iter=self.max_iter,
|
|
73
|
+
solver="lbfgs",
|
|
74
|
+
random_state=42,
|
|
75
|
+
)
|
|
76
|
+
self.model.fit(X_train, y_train, sample_weight=sw_train)
|
|
77
|
+
|
|
78
|
+
# Lightweight Platt scaling calibration on a holdout from training data
|
|
79
|
+
n = len(y_train)
|
|
80
|
+
cal_size = min(2000, n // 5)
|
|
81
|
+
idx = rng.choice(n, cal_size, replace=False)
|
|
82
|
+
X_cal, y_cal = X_train[idx], y_train[idx]
|
|
83
|
+
|
|
84
|
+
raw_proba = self.model.predict_proba(X_cal)
|
|
85
|
+
# Fit per-class sigmoid: P(y=c|raw) = sigmoid(A * logit(raw) + B)
|
|
86
|
+
self.calib_A = np.ones(NUM_CLASSES, dtype=np.float32)
|
|
87
|
+
self.calib_B = np.zeros(NUM_CLASSES, dtype=np.float32)
|
|
88
|
+
|
|
89
|
+
for c in range(NUM_CLASSES):
|
|
90
|
+
targets = (y_cal == c).astype(np.float32)
|
|
91
|
+
if targets.sum() < 5 or (1 - targets).sum() < 5:
|
|
92
|
+
continue
|
|
93
|
+
raw = raw_proba[:, c].clip(1e-7, 1 - 1e-7)
|
|
94
|
+
logit = np.log(raw / (1 - raw))
|
|
95
|
+
# Simple linear fit: A, B via least squares
|
|
96
|
+
# Platt scaling: minimize targets * log(sigmoid(A*l+B)) + (1-targets) * log(1-sigmoid(A*l+B))
|
|
97
|
+
# Use a robust 2-parameter fit
|
|
98
|
+
best_A, best_B = 1.0, 0.0
|
|
99
|
+
best_loss = float("inf")
|
|
100
|
+
for A_try in [0.5, 1.0, 1.5, 2.0]:
|
|
101
|
+
for B_try in [-1.0, -0.5, 0.0, 0.5, 1.0]:
|
|
102
|
+
s = _sigmoid(A_try * logit + B_try)
|
|
103
|
+
s = s.clip(1e-7, 1 - 1e-7)
|
|
104
|
+
loss = -np.mean(targets * np.log(s) + (1 - targets) * np.log(1 - s))
|
|
105
|
+
if loss < best_loss:
|
|
106
|
+
best_loss = loss
|
|
107
|
+
best_A, best_B = A_try, B_try
|
|
108
|
+
self.calib_A[c] = best_A
|
|
109
|
+
self.calib_B[c] = best_B
|
|
110
|
+
|
|
111
|
+
del X_cal, y_cal, raw_proba
|
|
112
|
+
import gc
|
|
113
|
+
gc.collect()
|
|
114
|
+
|
|
115
|
+
def _calibrate_proba(self, raw_proba: np.ndarray) -> np.ndarray:
|
|
116
|
+
"""Apply sigmoid calibration to raw probabilities."""
|
|
117
|
+
if self.calib_A is None:
|
|
118
|
+
return raw_proba
|
|
119
|
+
|
|
120
|
+
calibrated = np.zeros_like(raw_proba)
|
|
121
|
+
for c in range(raw_proba.shape[1]):
|
|
122
|
+
raw = raw_proba[:, c].clip(1e-7, 1 - 1e-7)
|
|
123
|
+
logit = np.log(raw / (1 - raw))
|
|
124
|
+
calibrated[:, c] = _sigmoid(self.calib_A[c] * logit + self.calib_B[c])
|
|
125
|
+
|
|
126
|
+
# Renormalize
|
|
127
|
+
row_sums = calibrated.sum(axis=1, keepdims=True)
|
|
128
|
+
row_sums = np.maximum(row_sums, 1e-10)
|
|
129
|
+
calibrated /= row_sums
|
|
130
|
+
return calibrated
|
|
131
|
+
|
|
132
|
+
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
|
133
|
+
"""Get calibrated probabilities. Always returns shape (n, 3)."""
|
|
134
|
+
if self.model is None:
|
|
135
|
+
raise RuntimeError("Model not trained. Call train() first.")
|
|
136
|
+
|
|
137
|
+
raw_proba = self.model.predict_proba(X)
|
|
138
|
+
|
|
139
|
+
# Ensure 3-column output
|
|
140
|
+
if raw_proba.shape[1] < NUM_CLASSES:
|
|
141
|
+
full = np.zeros((raw_proba.shape[0], NUM_CLASSES), dtype=np.float32)
|
|
142
|
+
for i, c in enumerate(self.model.classes_):
|
|
143
|
+
full[:, c] = raw_proba[:, i]
|
|
144
|
+
for c in range(NUM_CLASSES):
|
|
145
|
+
if c not in self.model.classes_:
|
|
146
|
+
full[:, c] = 1.0 / NUM_CLASSES
|
|
147
|
+
row_sums = full.sum(axis=1, keepdims=True)
|
|
148
|
+
full /= np.maximum(row_sums, 1e-10)
|
|
149
|
+
raw_proba = full
|
|
150
|
+
|
|
151
|
+
return self._calibrate_proba(raw_proba.astype(np.float32))
|
|
152
|
+
|
|
153
|
+
def predict(self, X: np.ndarray) -> tuple:
|
|
154
|
+
"""
|
|
155
|
+
Predict class and probabilities.
|
|
156
|
+
Returns (class_indices, probabilities) where probabilities shape is (n_samples, 3)
|
|
157
|
+
"""
|
|
158
|
+
proba = self.predict_proba(X)
|
|
159
|
+
|
|
160
|
+
max_proba = np.max(proba, axis=1)
|
|
161
|
+
below_threshold = max_proba < self.min_confidence
|
|
162
|
+
|
|
163
|
+
class_indices = np.argmax(proba, axis=1)
|
|
164
|
+
class_indices[below_threshold] = LABEL_TO_IDX["uncertain"]
|
|
165
|
+
|
|
166
|
+
return class_indices, proba
|
|
167
|
+
|
|
168
|
+
def get_config(self) -> dict:
|
|
169
|
+
return {
|
|
170
|
+
"C": self.C,
|
|
171
|
+
"max_iter": self.max_iter,
|
|
172
|
+
"min_confidence": self.min_confidence,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_config(cls, config: dict) -> "NameClassifier":
|
|
177
|
+
return cls(
|
|
178
|
+
C=config.get("C", 1.0),
|
|
179
|
+
max_iter=config.get("max_iter", 1000),
|
|
180
|
+
min_confidence=config.get("min_confidence", 0.70),
|
|
181
|
+
)
|
genderfluid/cli.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Full-featured CLI for genderfluid-tiny."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
SUBCOMMANDS = {"predict", "stats", "benchmark", "interactive"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_model(model_path=None):
|
|
13
|
+
from genderfluid import GenderfluidModel
|
|
14
|
+
return GenderfluidModel(model_path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_result(result: dict, color: bool = False) -> str:
|
|
18
|
+
lines = [f"Name: {result['name']}", ""]
|
|
19
|
+
|
|
20
|
+
girl = result["girl_associated_probability"] * 100
|
|
21
|
+
boy = result["boy_associated_probability"] * 100
|
|
22
|
+
unc = result["uncertain_probability"] * 100
|
|
23
|
+
|
|
24
|
+
if color:
|
|
25
|
+
lines.append(f"Girl-associated: \033[35m{girl:.1f}%\033[0m")
|
|
26
|
+
lines.append(f"Boy-associated: \033[34m{boy:.1f}%\033[0m")
|
|
27
|
+
lines.append(f"Uncertain: {unc:.1f}%")
|
|
28
|
+
else:
|
|
29
|
+
lines.append(f"Girl-associated: {girl:.1f}%")
|
|
30
|
+
lines.append(f"Boy-associated: {boy:.1f}%")
|
|
31
|
+
lines.append(f"Uncertain: {unc:.1f}%")
|
|
32
|
+
|
|
33
|
+
lines.append("")
|
|
34
|
+
lines.append(f"Classification: {result['classification']}")
|
|
35
|
+
lines.append(f"Confidence: {result['confidence']}")
|
|
36
|
+
|
|
37
|
+
if "warning" in result:
|
|
38
|
+
lines.append(f"Warning: {result['warning']}")
|
|
39
|
+
if "context_warning" in result:
|
|
40
|
+
lines.append(f"Note: {result['context_warning']}")
|
|
41
|
+
return "\n".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_compare(results: list[dict]) -> str:
|
|
45
|
+
lines = [
|
|
46
|
+
f"{'Name':<25} {'Classification':<20} {'Girl':>6} {'Boy':>6} {'Confidence':<10}",
|
|
47
|
+
"-" * 70,
|
|
48
|
+
]
|
|
49
|
+
for r in results:
|
|
50
|
+
g = f"{r['girl_associated_probability']*100:.0f}%"
|
|
51
|
+
b = f"{r['boy_associated_probability']*100:.0f}%"
|
|
52
|
+
lines.append(f"{r['name']:<25} {r['classification']:<20} {g:>6} {b:>6} {r['confidence']:<10}")
|
|
53
|
+
return "\n".join(lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def interactive_mode(model=None, color: bool = False):
|
|
57
|
+
print("Name Gender Association Predictor")
|
|
58
|
+
print("Type 'quit' to exit.\n")
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
name = input("Name > ").strip()
|
|
63
|
+
except (EOFError, KeyboardInterrupt):
|
|
64
|
+
print()
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if not name or name.lower() in ("quit", "exit", "q"):
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
t0 = time.time()
|
|
72
|
+
if model:
|
|
73
|
+
result = model.predict(name)
|
|
74
|
+
else:
|
|
75
|
+
from genderfluid import predict_name
|
|
76
|
+
result = predict_name(name)
|
|
77
|
+
elapsed = (time.time() - t0) * 1000
|
|
78
|
+
print()
|
|
79
|
+
print(format_result(result, color=color))
|
|
80
|
+
print(f" ({elapsed:.1f} ms)")
|
|
81
|
+
print()
|
|
82
|
+
except FileNotFoundError as e:
|
|
83
|
+
print(f"Error: {e}")
|
|
84
|
+
break
|
|
85
|
+
except Exception as e:
|
|
86
|
+
print(f"Error: {e}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_predict(args):
|
|
90
|
+
model = _load_model(args.model)
|
|
91
|
+
|
|
92
|
+
if args.file:
|
|
93
|
+
with open(args.file, "r", encoding="utf-8") as f:
|
|
94
|
+
names = [line.strip() for line in f if line.strip()]
|
|
95
|
+
|
|
96
|
+
t0 = time.time()
|
|
97
|
+
results = model.predict_batch(names)
|
|
98
|
+
elapsed = time.time() - t0
|
|
99
|
+
|
|
100
|
+
if args.json:
|
|
101
|
+
for r in results:
|
|
102
|
+
print(json.dumps(r, ensure_ascii=False))
|
|
103
|
+
else:
|
|
104
|
+
print(format_compare(results))
|
|
105
|
+
|
|
106
|
+
print(f"\n{len(names)} names in {elapsed*1000:.1f} ms ({len(names)/elapsed:.0f} names/sec)")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if args.compare:
|
|
110
|
+
names = [n.strip() for n in args.compare if n.strip()]
|
|
111
|
+
if not names:
|
|
112
|
+
print("Error: no names provided")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
t0 = time.time()
|
|
116
|
+
results = model.predict_batch(names)
|
|
117
|
+
elapsed = time.time() - t0
|
|
118
|
+
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
121
|
+
else:
|
|
122
|
+
print(format_compare(results))
|
|
123
|
+
print(f"\n{len(names)} names in {elapsed*1000:.1f} ms")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if args.name:
|
|
127
|
+
t0 = time.time()
|
|
128
|
+
result = model.predict(args.name)
|
|
129
|
+
elapsed = (time.time() - t0) * 1000
|
|
130
|
+
|
|
131
|
+
if args.json:
|
|
132
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
133
|
+
else:
|
|
134
|
+
print(format_result(result, color=args.color))
|
|
135
|
+
if args.verbose:
|
|
136
|
+
print(f"\n Latency: {elapsed:.1f} ms")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
print("Usage: genderfluid predict <name> [options]")
|
|
140
|
+
print(" genderfluid predict --compare <name1> <name2> ...")
|
|
141
|
+
print(" genderfluid predict --file names.txt")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_stats(args):
|
|
145
|
+
model = _load_model(args.model)
|
|
146
|
+
meta = model.metadata
|
|
147
|
+
size = os.path.getsize(model.model_path)
|
|
148
|
+
|
|
149
|
+
print("Model Statistics")
|
|
150
|
+
print("=" * 40)
|
|
151
|
+
print(f" Model: genderfluid-tiny")
|
|
152
|
+
print(f" Version: {meta.get('version', 'unknown')}")
|
|
153
|
+
print(f" Size: {size / (1024*1024):.2f} MB ({size:,} bytes)")
|
|
154
|
+
if "feature_dimensions" in meta:
|
|
155
|
+
print(f" Features: {meta['feature_dimensions']}")
|
|
156
|
+
if "data_source" in meta:
|
|
157
|
+
print(f" Data: {meta['data_source']}")
|
|
158
|
+
if "train_size" in meta:
|
|
159
|
+
print(f" Train: {meta['train_size']:,}")
|
|
160
|
+
if "val_size" in meta:
|
|
161
|
+
print(f" Validation: {meta['val_size']:,}")
|
|
162
|
+
if "test_size" in meta:
|
|
163
|
+
print(f" Test: {meta['test_size']:,}")
|
|
164
|
+
if "test_f1" in meta:
|
|
165
|
+
print(f" Test F1: {meta['test_f1']:.3f}")
|
|
166
|
+
if "validation_f1" in meta:
|
|
167
|
+
print(f" Val F1: {meta['validation_f1']:.3f}")
|
|
168
|
+
print(f" Classes: girl-associated, boy-associated, uncertain")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_benchmark(args):
|
|
172
|
+
from genderfluid import GenderfluidModel
|
|
173
|
+
|
|
174
|
+
print("Benchmark")
|
|
175
|
+
print("=" * 40)
|
|
176
|
+
|
|
177
|
+
t0 = time.time()
|
|
178
|
+
model = GenderfluidModel(args.model)
|
|
179
|
+
load_time = (time.time() - t0) * 1000
|
|
180
|
+
size = os.path.getsize(model.model_path)
|
|
181
|
+
print(f" Model size: {size / (1024*1024):.2f} MB")
|
|
182
|
+
print(f" Loading time: {load_time:.1f} ms")
|
|
183
|
+
|
|
184
|
+
test_names = ["Emma", "James", "Alex", "Michelle Renatta Chan", "Max", "Taylor",
|
|
185
|
+
"Elva Retta", "Sam", "Jordan", "Chris"]
|
|
186
|
+
|
|
187
|
+
# Single name
|
|
188
|
+
times = []
|
|
189
|
+
for _ in range(10):
|
|
190
|
+
for name in test_names:
|
|
191
|
+
t0 = time.time()
|
|
192
|
+
model.predict(name)
|
|
193
|
+
times.append((time.time() - t0) * 1000)
|
|
194
|
+
avg_single = sum(times) / len(times)
|
|
195
|
+
print(f" Single name: {avg_single:.2f} ms")
|
|
196
|
+
|
|
197
|
+
# Batch
|
|
198
|
+
for batch_size in [10, 100, 1000]:
|
|
199
|
+
batch = (test_names * (batch_size // len(test_names) + 1))[:batch_size]
|
|
200
|
+
t0 = time.time()
|
|
201
|
+
model.predict_batch(batch)
|
|
202
|
+
elapsed = (time.time() - t0) * 1000
|
|
203
|
+
throughput = batch_size / (elapsed / 1000)
|
|
204
|
+
print(f" Batch {batch_size:>4}: {elapsed:>7.1f} ms ({throughput:,.0f} names/sec)")
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
import resource
|
|
208
|
+
mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
|
209
|
+
print(f" Peak RSS: {mem:.0f} MB")
|
|
210
|
+
except (ImportError, AttributeError):
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _build_parser():
|
|
215
|
+
parser = argparse.ArgumentParser(
|
|
216
|
+
prog="genderfluid",
|
|
217
|
+
description="genderfluid-tiny: name gender association classifier",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument("--version", action="version", version="genderfluid-tiny 1.0.0")
|
|
220
|
+
parser.add_argument("--color", action="store_true", help="colored output")
|
|
221
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="verbose output")
|
|
222
|
+
|
|
223
|
+
sub = parser.add_subparsers(dest="command")
|
|
224
|
+
|
|
225
|
+
p_predict = sub.add_parser("predict", help="predict gender association for a name")
|
|
226
|
+
p_predict.add_argument("name", nargs="?", help="name to classify")
|
|
227
|
+
p_predict.add_argument("--file", "-f", help="file with one name per line")
|
|
228
|
+
p_predict.add_argument("--compare", "-c", nargs="+", help="compare multiple names")
|
|
229
|
+
p_predict.add_argument("--json", "-j", action="store_true", help="output JSON")
|
|
230
|
+
p_predict.add_argument("--model", "-m", help="path to model file")
|
|
231
|
+
|
|
232
|
+
p_stats = sub.add_parser("stats", help="show model statistics")
|
|
233
|
+
p_stats.add_argument("--model", "-m", help="path to model file")
|
|
234
|
+
|
|
235
|
+
p_bench = sub.add_parser("benchmark", help="run inference benchmark")
|
|
236
|
+
p_bench.add_argument("--model", "-m", help="path to model file")
|
|
237
|
+
|
|
238
|
+
p_inter = sub.add_parser("interactive", help="interactive prediction mode")
|
|
239
|
+
p_inter.add_argument("--model", "-m", help="path to model file")
|
|
240
|
+
|
|
241
|
+
return parser
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
parser = _build_parser()
|
|
246
|
+
|
|
247
|
+
# Detect backward-compatible calling: predict.py "Elva Retta"
|
|
248
|
+
# sys.argv[0] ends with predict.py and argv[1] is not a subcommand or flag
|
|
249
|
+
if len(sys.argv) > 1:
|
|
250
|
+
first = sys.argv[1]
|
|
251
|
+
if first not in SUBCOMMANDS and not first.startswith("-") and first != "--help":
|
|
252
|
+
# Looks like a bare name -- treat as: predict <name>
|
|
253
|
+
sys.argv = [sys.argv[0], "predict"] + sys.argv[1:]
|
|
254
|
+
|
|
255
|
+
args = parser.parse_args()
|
|
256
|
+
|
|
257
|
+
if args.command == "predict":
|
|
258
|
+
cmd_predict(args)
|
|
259
|
+
elif args.command == "stats":
|
|
260
|
+
cmd_stats(args)
|
|
261
|
+
elif args.command == "benchmark":
|
|
262
|
+
cmd_benchmark(args)
|
|
263
|
+
elif args.command == "interactive":
|
|
264
|
+
interactive_mode(_load_model(getattr(args, 'model', None)))
|
|
265
|
+
else:
|
|
266
|
+
parser.print_help()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
main()
|