| |
| """Word-difficulty regressor for the game «Шляпа» (thehat.tech). |
| |
| Predicts the Elo-style difficulty rating E of a Russian noun from the word |
| itself plus its corpus frequency. See README.md for metrics and background. |
| |
| Usage: |
| from modeling import WordDifficultyPredictor |
| predictor = WordDifficultyPredictor.from_dir(".") |
| predictor.predict(["кровать", "соразмерность"]) # -> [~38.5, ~60.0] |
| """ |
|
|
| import json |
| import os |
|
|
| import numpy as np |
| import torch |
| from transformers import AutoModel, AutoTokenizer |
|
|
| MAX_LEN = 16 |
|
|
|
|
| class BertFreqRegressor(torch.nn.Module): |
| def __init__(self, base_model="DeepPavlov/rubert-base-cased"): |
| super().__init__() |
| self.bert = AutoModel.from_pretrained(base_model) |
| hidden = self.bert.config.hidden_size |
| self.head = torch.nn.Sequential( |
| torch.nn.Linear(hidden + 2, 128), |
| torch.nn.GELU(), |
| torch.nn.Dropout(0.1), |
| torch.nn.Linear(128, 1), |
| ) |
|
|
| def forward(self, ids, mask, extra): |
| out = self.bert(input_ids=ids, attention_mask=mask) |
| m = mask.unsqueeze(-1).float() |
| pooled = (out.last_hidden_state * m).sum(1) / m.sum(1).clamp(min=1) |
| return self.head(torch.cat([pooled, extra], dim=-1)).squeeze(-1) |
|
|
|
|
| class WordDifficultyPredictor: |
| def __init__(self, model, tokenizer, freq, mean, std, lf_mean, lf_std, |
| device=None): |
| self.model = model |
| self.tokenizer = tokenizer |
| self.freq = freq |
| self.mean, self.std = mean, std |
| self.lf_mean, self.lf_std = lf_mean, lf_std |
| self.device = device or torch.device( |
| "mps" if torch.backends.mps.is_available() |
| else "cuda" if torch.cuda.is_available() else "cpu") |
| self.model.to(self.device).eval() |
|
|
| @classmethod |
| def from_dir(cls, path): |
| """Load from a local directory (e.g. a huggingface_hub snapshot).""" |
| ckpt = torch.load(os.path.join(path, "model.pt"), map_location="cpu", |
| weights_only=True) |
| model = BertFreqRegressor(ckpt.get("model_name", |
| "DeepPavlov/rubert-base-cased")) |
| model.load_state_dict(ckpt["state_dict"]) |
| tokenizer = AutoTokenizer.from_pretrained(path) |
| with open(os.path.join(path, "word_frequency.json"), |
| encoding="utf-8") as fh: |
| freq = json.load(fh) |
| return cls(model, tokenizer, freq, ckpt["mean"], ckpt["std"], |
| ckpt["lf_mean"], ckpt["lf_std"]) |
|
|
| @classmethod |
| def from_hub(cls, repo_id="nzinov/thehat-word-difficulty"): |
| from huggingface_hub import snapshot_download |
| return cls.from_dir(snapshot_download(repo_id)) |
|
|
| @torch.no_grad() |
| def predict(self, words): |
| """Difficulty ratings E for a list of Russian words (lowercase).""" |
| words = [w.strip().lower() for w in words] |
| enc = self.tokenizer(words, truncation=True, max_length=MAX_LEN, |
| padding="max_length", return_tensors="pt") |
| feats = [] |
| for w in words: |
| f = self.freq.get(w) |
| has = 1.0 if f is not None else 0.0 |
| lf = ((np.log1p(f) - self.lf_mean) / self.lf_std |
| if f is not None else 0.0) |
| feats.append([lf, has]) |
| extra = torch.tensor(feats, dtype=torch.float32) |
| pred = self.model(enc["input_ids"].to(self.device), |
| enc["attention_mask"].to(self.device), |
| extra.to(self.device)) |
| return (pred.cpu().numpy() * self.std + self.mean).tolist() |
|
|
|
|
| if __name__ == "__main__": |
| import sys |
| predictor = WordDifficultyPredictor.from_dir( |
| os.path.dirname(os.path.abspath(__file__)) or ".") |
| words = sys.argv[1:] or ["кровать", "луна", "синоним", "соразмерность"] |
| for word, e in zip(words, predictor.predict(words)): |
| print(f"{word:20s} E = {e:.1f}") |
|
|