shoshan 0.4.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.
Files changed (37) hide show
  1. shoshan-0.4.0/LICENSE +21 -0
  2. shoshan-0.4.0/NOTICE +27 -0
  3. shoshan-0.4.0/PKG-INFO +322 -0
  4. shoshan-0.4.0/README.md +298 -0
  5. shoshan-0.4.0/pyproject.toml +39 -0
  6. shoshan-0.4.0/setup.cfg +4 -0
  7. shoshan-0.4.0/src/shoshan/__init__.py +12 -0
  8. shoshan-0.4.0/src/shoshan/cli.py +62 -0
  9. shoshan-0.4.0/src/shoshan/data/coverage_bypass_forms.txt +3090 -0
  10. shoshan-0.4.0/src/shoshan/data/suppletives.csv +347 -0
  11. shoshan-0.4.0/src/shoshan/data/wiktionary_acronyms.csv +3429 -0
  12. shoshan-0.4.0/src/shoshan/data.py +41 -0
  13. shoshan-0.4.0/src/shoshan/doc_text.py +133 -0
  14. shoshan-0.4.0/src/shoshan/edit_script.py +116 -0
  15. shoshan-0.4.0/src/shoshan/hub.py +25 -0
  16. shoshan-0.4.0/src/shoshan/infer.py +544 -0
  17. shoshan-0.4.0/src/shoshan/lemma_bank.py +138 -0
  18. shoshan-0.4.0/src/shoshan/model_joint.py +153 -0
  19. shoshan-0.4.0/src/shoshan/normalize.py +165 -0
  20. shoshan-0.4.0/src/shoshan/runtime.py +62 -0
  21. shoshan-0.4.0/src/shoshan/suppletive.py +58 -0
  22. shoshan-0.4.0/src/shoshan/text.py +168 -0
  23. shoshan-0.4.0/src/shoshan.egg-info/PKG-INFO +322 -0
  24. shoshan-0.4.0/src/shoshan.egg-info/SOURCES.txt +35 -0
  25. shoshan-0.4.0/src/shoshan.egg-info/dependency_links.txt +1 -0
  26. shoshan-0.4.0/src/shoshan.egg-info/entry_points.txt +2 -0
  27. shoshan-0.4.0/src/shoshan.egg-info/requires.txt +5 -0
  28. shoshan-0.4.0/src/shoshan.egg-info/top_level.txt +1 -0
  29. shoshan-0.4.0/tests/test_annotate_offsets.py +109 -0
  30. shoshan-0.4.0/tests/test_api_contracts.py +126 -0
  31. shoshan-0.4.0/tests/test_drifting_corpus.py +146 -0
  32. shoshan-0.4.0/tests/test_encode_once.py +85 -0
  33. shoshan-0.4.0/tests/test_presentation_fold.py +154 -0
  34. shoshan-0.4.0/tests/test_source_is_nfc_stable.py +69 -0
  35. shoshan-0.4.0/tests/test_span_offsets.py +183 -0
  36. shoshan-0.4.0/tests/test_spans.py +342 -0
  37. shoshan-0.4.0/tests/test_tokenizer_classes.py +97 -0
shoshan-0.4.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noam Ordan
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.
shoshan-0.4.0/NOTICE ADDED
@@ -0,0 +1,27 @@
1
+ Shoshan — third-party provenance notice
2
+ =======================================
3
+
4
+ The MIT license in LICENSE covers the CODE in this repository. It does not, and
5
+ cannot, relicense the model weights or the data they were derived from. Those
6
+ carry their own terms, recorded here.
7
+
8
+ Model weights
9
+ -------------
10
+ The encoder is fine-tuned from DictaBERT (dicta-il/dictabert) and remains subject
11
+ to that model's license.
12
+
13
+ https://huggingface.co/dicta-il/dictabert
14
+
15
+ Training and evaluation data
16
+ ----------------------------
17
+ Derived from the IAHLT Hebrew Universal Dependencies treebank (Knesset and
18
+ Wikipedia portions) and from public Hebrew lexicons. The bundled acronym list is
19
+ from Wiktionary (CC BY-SA).
20
+
21
+ Full provenance and terms: docs/DATA_STATEMENT.md
22
+ Dataset card: docs/DATASET_CARD.md
23
+
24
+ Acknowledgements
25
+ ----------------
26
+ We thank Avner Algom and the IAHLT for the treebank data, and for permission to
27
+ release the out-of-domain evaluation sentences.
shoshan-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,322 @@
1
+ Metadata-Version: 2.4
2
+ Name: shoshan
3
+ Version: 0.4.0
4
+ Summary: A zero-hallucination Hebrew lemmatizer (retrieve, then transduce).
5
+ Author: Noam Ordan
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ivrit/shoshan
8
+ Project-URL: Weights, https://huggingface.co/HebArabNlpProject/shoshan
9
+ Project-URL: Demo, https://huggingface.co/spaces/noamor/shoshan-demo
10
+ Keywords: hebrew,lemmatization,nlp,retrieval,morphology
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ License-File: NOTICE
18
+ Requires-Dist: torch>=2.0
19
+ Requires-Dist: transformers>=4.30
20
+ Requires-Dist: numpy
21
+ Requires-Dist: pandas
22
+ Requires-Dist: huggingface_hub>=0.20
23
+ Dynamic: license-file
24
+
25
+ # Shoshan
26
+
27
+ A Hebrew lemmatizer that does not hallucinate.
28
+
29
+ Give it a word in a sentence and it returns the word's lemma — its dictionary
30
+ form. The trick is that Shoshan never invents a word. It first **retrieves** the
31
+ lemma from a fixed bank of real Hebrew lemmas. When the best match does not look
32
+ like it could be a form of the input word, it falls back and **transduces** the
33
+ lemma by editing the input word itself (stripping a prefix, fixing a suffix).
34
+ Either way the output is a real lemma or a bounded edit of the word you gave it.
35
+ It cannot return a string that came from nowhere.
36
+
37
+ - **Weights:** <https://huggingface.co/HebArabNlpProject/shoshan>
38
+ - **Demo:** <https://huggingface.co/spaces/noamor/shoshan-demo>
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install git+https://github.com/ivrit/shoshan.git
44
+ ```
45
+
46
+ The weights (about 1 GB) download from the Hugging Face Hub the first time you
47
+ load the model and are cached after that.
48
+
49
+ ## Use it
50
+
51
+ Three entry points, one per unit of text:
52
+
53
+ | call | input | reach for it when you have… |
54
+ |---|---|---|
55
+ | `lemma(word, sentence)` | one word in context | a single look-up |
56
+ | `annotate(sentence)` | one sentence | a sentence to tag |
57
+ | `lemmatize_text(source)` | a string, file, or folder | a **whole document** to lemmatize or **index for search** |
58
+
59
+ ### One word, one sentence
60
+
61
+ ```python
62
+ from shoshan import Lemmatizer
63
+
64
+ lz = Lemmatizer.from_pretrained() # downloads + caches
65
+
66
+ # lemma(word, sentence): the lemma of that one word,
67
+ # read in context; the rest of the sentence is left alone.
68
+ lz.lemma("המחברות", "המורה חילקה את המחברות לתלמידים בכיתה.") # 'מחברת'
69
+ lz.lemma("בספרו", "הוא כתב על כך בספרו האחרון.") # 'ספר'
70
+
71
+ # annotate(sentence): lemmatize every word in the sentence.
72
+ for tok in lz.annotate("הילדים שיחקו בגן"):
73
+ print(tok["form"], "→", tok["lemma"], tok["source"])
74
+ ```
75
+
76
+ The Hebrew above, romanized for non-Hebrew readers (Modern-Israeli phonemic):
77
+ *המחברות* ha-makhbarot ('the notebooks') → *מחברת* makhberet; *בספרו* bi-sfaro ('in his
78
+ book') → *ספר* sefer; and *הילדים שיחקו בגן* ha-yeladim sikhaku ba-gan ('the
79
+ children played in the garden') → *ילד* yeled, *שיחק* sikhek, *גן* gan.
80
+
81
+ `source` is `"retrieved"` when the lemma came from the bank and `"transduced"`
82
+ when the edit-script fallback produced it (this is how out-of-vocabulary words
83
+ are handled).
84
+
85
+ For information retrieval you usually drop closed-class function words (prepositions,
86
+ pronouns, conjunctions, …). Pass `blank_function_words=True` and those tokens come
87
+ back with an empty lemma and `source="function"`, so you can skip them:
88
+
89
+ ```python
90
+ lz = Lemmatizer.from_pretrained(blank_function_words=True)
91
+ ```
92
+
93
+ ### Whole documents and search indexing
94
+
95
+ `lemmatize_text(source)` is the call for **complete texts**. Give it a raw
96
+ string, a file path, or a folder path; it segments paragraphs → sentences →
97
+ word tokens and returns a **doc dict**:
98
+
99
+ ```python
100
+ doc = lz.lemmatize_text("הילדים שיחקו בגן. הם בנו ארמון בחול.")
101
+
102
+ doc["analyzed_text"] # the lemmas in order, as one string
103
+ doc["tokens"][0] # one record per word (shape):
104
+ # {'token': 'הילדים', 'start': 0, 'end': 6, 'lemma': 'ילד',
105
+ # 'pos': 'NOUN', 'source': 'bypass', 'score': ..., 'sent_id': 0}
106
+ ```
107
+
108
+ The doc dict has five keys:
109
+
110
+ | key | what it holds |
111
+ |---|---|
112
+ | `text` | the original input, echoed back |
113
+ | `tokens` | one record per word: `token, start, end, lemma, pos, source, score, sent_id` |
114
+ | `analyzed_text` | the lemmas in order, space-joined into one string |
115
+ | `es_tokens` | an Elasticsearch [`_analyze`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html)-style stream, ready to index |
116
+ | `unknown` | out-of-bank words worth annotating (deduped, with counts) |
117
+
118
+ **Character offsets round-trip.** Every token carries absolute offsets into
119
+ `text`, so `text[start:end] == token` — slice the original to highlight a hit
120
+ without re-tokenizing. The same holds for `annotate(sentence)`: each row's
121
+ `start` indexes the sentence you passed in, so
122
+ `sentence[start:start + len(form)] == form`.
123
+
124
+ **Ready for Elasticsearch.** Each `es_tokens` entry is a lemma with its source
125
+ span and position — `{token, start_offset, end_offset, position, type}` — the
126
+ same shape Elasticsearch's `_analyze` emits, so you can feed it straight into an
127
+ index and search by lemma while highlighting the original surface text.
128
+
129
+ **`source` tells you where each lemma came from:**
130
+
131
+ - `retrieved` — pulled from the lemma bank,
132
+ - `transduced` — produced by the edit-script fallback (out-of-vocabulary words),
133
+ - `bypass` — retrieved from the bank with the coverage gate **skipped**, because the
134
+ form is a pronoun or one of a list of high-frequency closed-class words whose lemma
135
+ the gate reliably distrusts for the wrong reason. This is common: on ordinary text it
136
+ is close to half of all tokens,
137
+ - `acronym` — a known Hebrew acronym, kept as its own lemma with the coverage gate
138
+ skipped (`צה"ל` → `צה"ל`),
139
+ - `suppletive` — a curated look-up for irregular / closed-class forms whose
140
+ lemma shares too few letters to retrieve (e.g. *היא* → *הוא*),
141
+
142
+ > **Lemma convention for pronouns.** Following the MILA canonical-lemma
143
+ > convention, **personal pronouns lemmatize to הוא** — not only *היא*, *הם*
144
+ > and *הן*, but also *אני*, *אתה* and *אנחנו*. This is deliberate, not a bug:
145
+ > the pronoun paradigm is treated as one lexeme with הוא as its citation
146
+ > form. If you need person or number, read them from the surface form or
147
+ > from `pos`, not from the lemma.
148
+ >
149
+ > The look-up is keyed on the surface form **and the predicted POS**, so it
150
+ > applies when the word is actually tagged as a pronoun. In a sentence where
151
+ > the POS head mis-tags one — ill-formed agreement, say — the form comes back
152
+ > unchanged instead. Do not rely on the mapping as an invariant over arbitrary
153
+ > text; check `pos` alongside it.
154
+ - `function` — only with `blank_function_words=True`: closed-class stopwords
155
+ come back with an empty lemma and are kept in `tokens` (for provenance) but
156
+ dropped from `es_tokens` and `analyzed_text`.
157
+
158
+ **Files and folders.** Pass a file path and you get one doc dict back with a
159
+ `path` key added; pass a folder and you get `{relative_path: doc dict}` for
160
+ every text file under it (`files_glob="*.txt"`, recursive by default):
161
+
162
+ ```python
163
+ doc = lz.lemmatize_text("notes.txt") # one doc dict, plus doc["path"]
164
+ docs = lz.lemmatize_text("corpus/") # {relative_path: doc dict}
165
+ ```
166
+
167
+ From the command line:
168
+
169
+ ```bash
170
+ shoshan "המורה חילקה את המחברות לתלמידים בכיתה"
171
+ shoshan --csv input.csv output.csv # input columns: form,sentence[,pos]
172
+ ```
173
+
174
+ ## How it works
175
+
176
+ One DictaBERT encoder produces a vector for the target word in its sentence.
177
+ Three things hang off that vector:
178
+
179
+ 1. **Retrieval** — cosine similarity against a bank of ~118k pre-encoded lemmas.
180
+ 2. **A coverage gate** — checks whether the retrieved lemma's characters actually
181
+ appear, in order, inside the surface word. If they do not, the retrieval is
182
+ probably wrong (an unknown word), so the router does not trust it.
183
+ 3. **An edit-script head** — a learned, rule-free transformation of the *word*
184
+ into its lemma, used only when the gate distrusts retrieval.
185
+
186
+ Two routes skip step 2 deliberately, and between them they cover a large share of
187
+ ordinary text: a curated suppletive look-up and a known-acronym look-up answer directly
188
+ (`source` `suppletive` / `acronym`), and a list of pronouns and high-frequency
189
+ closed-class forms accepts retrieval without the coverage check (`source` `bypass`).
190
+ Those forms are exactly the ones whose lemma shares too few letters with the surface for
191
+ the gate to judge fairly. One consequence is worth knowing if you curate: `write_miss_log`
192
+ only ever sees tokens the gate actually judged, so a bypassed or suppletive token can
193
+ never appear in the worklist.
194
+
195
+ Because step 3 can only delete from or affix to the input word, the system has a
196
+ bounded output: it never produces an unrelated string. Adding vocabulary is just
197
+ adding rows to the bank and re-encoding them — no retraining.
198
+
199
+ ### Finding words to curate
200
+
201
+ The system tells you where it is unsure. A token is flagged when the bank's best lemma is
202
+ morphologically implausible for the surface form (the coverage gate distrusts the
203
+ retrieval) — i.e. a likely out-of-vocabulary word. Turn on `log_misses` and the model
204
+ collects these; `write_miss_log` writes them as a **frequency-sorted worklist** — the
205
+ word-forms most worth annotating or adding to the lexicon, commonest first.
206
+
207
+ ```python
208
+ lz = Lemmatizer.from_pretrained(log_misses=True)
209
+ lz.lemmatize(corpus_rows) # run over your text
210
+ lz.write_miss_log("to_curate.csv") # see the columns below
211
+ ```
212
+
213
+ or from the command line:
214
+
215
+ ```bash
216
+ shoshan --csv corpus.csv out.csv --miss-log to_curate.csv
217
+ ```
218
+
219
+ The worklist has eight columns, one row per (wordform, predicted POS) pair:
220
+
221
+ | column | what it holds |
222
+ |---|---|
223
+ | `wordform` | the surface form, as it appeared in your text |
224
+ | `predicted_pos` | the POS the model assigned it |
225
+ | `count` | how often the pair occurred; the file is sorted by this, commonest first |
226
+ | `predicted_lemma` | the lemma the system finally returned |
227
+ | `retrieved_lemma` | the bank's best candidate, which the coverage gate distrusted |
228
+ | `mean_coverage` | mean coverage-gate score over the occurrences |
229
+ | `mean_sim` | mean retrieval similarity over the occurrences |
230
+ | `reason` | why it was flagged (the commonest reason, when they differ) |
231
+
232
+ Since extending the system is just adding lemmas to the bank and re-encoding (no
233
+ retraining), this closes the loop: the model surfaces its own gaps, you curate the
234
+ highest-frequency ones, and coverage improves without a training step.
235
+
236
+ ## Results
237
+
238
+ The released model is trained only on the openly redistributable **Knesset +
239
+ Wikipedia** portions of the IAHLT Hebrew UD treebank, plus public lexicons. It is
240
+ evaluated on held-out domains it never saw in training.
241
+
242
+ **Exact-match lemma accuracy** (Shoshan):
243
+
244
+ | split | overall | seen form | unseen form |
245
+ |---|---|---|---|
246
+ | in-domain | 94.3% | 95.4% | 85.6% |
247
+ | out-of-domain | 92.4% | 95.1% | 81.2% |
248
+
249
+ Accuracy on words whose surface form was seen in training transfers almost intact
250
+ across domains (~95%); the gap sits in the genuinely unseen tail, which is exactly
251
+ where the edit-script transducer operates.
252
+
253
+ **Versus DictaBERT-lex** (out-of-domain). Exact match is not a fair cross-system
254
+ metric here: the two systems follow different lemma conventions (article stripping,
255
+ multi-word handling), so scoring one against the other's gold understates it. We
256
+ compare instead on convention-invariant **B³ consistency** (do inflections of a
257
+ word cluster together?) and on **low-overlap errors** (predictions that share too
258
+ little with the input word to be one of its forms).
259
+
260
+ | metric (out-of-domain) | Shoshan | DictaBERT-lex |
261
+ |---|---|---|
262
+ | B³ precision | **0.965** | 0.906 |
263
+ | B³ recall | **0.953** | 0.932 |
264
+ | B³ F1 | **0.959** | 0.919 |
265
+ | Low-overlap errors, unseen words | **0.0%** | 12.3% |
266
+
267
+ The last row is the point of the design: on words neither system saw in training,
268
+ DictaBERT-lex — which predicts each lemma as a single token from its vocabulary —
269
+ rewrites about one in eight into a word the input could not have produced (or emits
270
+ an empty token), while Shoshan's bounded output rules that out.
271
+ DictaBERT-lex was trained on more data than we use here, including the domains we
272
+ hold out, so the comparison is conservative.
273
+
274
+ The training data and evaluation splits live in a companion dataset,
275
+ [`HebArabNlpProject/shoshan-data`](https://huggingface.co/datasets/HebArabNlpProject/shoshan-data), and
276
+ download on demand:
277
+
278
+ ```python
279
+ from shoshan import data
280
+ df = data.load("ood") # or "train" / "dev" / "test" / "oov" / "ood_bagatz" ...
281
+ ```
282
+
283
+ Numbers and the full write-up are in the paper (in preparation).
284
+
285
+ ### Consistency over canonical form
286
+
287
+ For information retrieval the exact lemma string barely matters. What matters is that
288
+ every inflection of a word lands on the *same* label and that different words stay apart.
289
+ That is what B³ measures, and it is why we report it — and why it is the fair way to compare
290
+ against a system that uses a different lemma convention. Surfaces that look nothing alike
291
+ collapse to one label:
292
+
293
+ ```
294
+ הורדתי · מוריד · הורידו · יוריד → הוריד
295
+ ```
296
+
297
+ (*horadeti · morid · horidu · yorid → horid* — four very different surface forms of
298
+ "download / take down", all conflated to one lemma.)
299
+
300
+ B³ is blind to the label itself: a system that consistently used a *non-standard* string for
301
+ this cluster would score exactly the same. In that sense the value is stemmer-like — what the
302
+ label is called does not matter, only that every form of a word reaches the same one. What B³
303
+ penalizes is the reverse: *splitting* one word across several labels. In this system that
304
+ happens only at the edit-script fallback, on a rare unseen form it cannot retrieve — it may
305
+ guess a stem that does not match the rest of the lexeme. That residue is why B³ recall is
306
+ 0.953 and not 1.0, and richer context shrinks it by routing more forms back to clean
307
+ retrieval.
308
+
309
+ ## About the name
310
+
311
+ *Shoshan* is for Even-Shoshan, the Hebrew dictionary, since a lemmatizer's whole
312
+ job is to hand you the dictionary entry behind an inflected word. It is also a
313
+ lily.
314
+
315
+ ## License and credit
316
+
317
+ Code is MIT (`LICENSE`). The weights and data carry their own terms, recorded in
318
+ `NOTICE`: the encoder is fine-tuned from
319
+ [DictaBERT](https://huggingface.co/dicta-il/dictabert), and the data comes from the
320
+ IAHLT Hebrew UD treebank and public Hebrew lexicons; see `docs/DATA_STATEMENT.md`.
321
+ We thank **Avner Algom** and the **IAHLT** for the treebank data and for
322
+ permission to release the out-of-domain evaluation sentences.
@@ -0,0 +1,298 @@
1
+ # Shoshan
2
+
3
+ A Hebrew lemmatizer that does not hallucinate.
4
+
5
+ Give it a word in a sentence and it returns the word's lemma — its dictionary
6
+ form. The trick is that Shoshan never invents a word. It first **retrieves** the
7
+ lemma from a fixed bank of real Hebrew lemmas. When the best match does not look
8
+ like it could be a form of the input word, it falls back and **transduces** the
9
+ lemma by editing the input word itself (stripping a prefix, fixing a suffix).
10
+ Either way the output is a real lemma or a bounded edit of the word you gave it.
11
+ It cannot return a string that came from nowhere.
12
+
13
+ - **Weights:** <https://huggingface.co/HebArabNlpProject/shoshan>
14
+ - **Demo:** <https://huggingface.co/spaces/noamor/shoshan-demo>
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install git+https://github.com/ivrit/shoshan.git
20
+ ```
21
+
22
+ The weights (about 1 GB) download from the Hugging Face Hub the first time you
23
+ load the model and are cached after that.
24
+
25
+ ## Use it
26
+
27
+ Three entry points, one per unit of text:
28
+
29
+ | call | input | reach for it when you have… |
30
+ |---|---|---|
31
+ | `lemma(word, sentence)` | one word in context | a single look-up |
32
+ | `annotate(sentence)` | one sentence | a sentence to tag |
33
+ | `lemmatize_text(source)` | a string, file, or folder | a **whole document** to lemmatize or **index for search** |
34
+
35
+ ### One word, one sentence
36
+
37
+ ```python
38
+ from shoshan import Lemmatizer
39
+
40
+ lz = Lemmatizer.from_pretrained() # downloads + caches
41
+
42
+ # lemma(word, sentence): the lemma of that one word,
43
+ # read in context; the rest of the sentence is left alone.
44
+ lz.lemma("המחברות", "המורה חילקה את המחברות לתלמידים בכיתה.") # 'מחברת'
45
+ lz.lemma("בספרו", "הוא כתב על כך בספרו האחרון.") # 'ספר'
46
+
47
+ # annotate(sentence): lemmatize every word in the sentence.
48
+ for tok in lz.annotate("הילדים שיחקו בגן"):
49
+ print(tok["form"], "→", tok["lemma"], tok["source"])
50
+ ```
51
+
52
+ The Hebrew above, romanized for non-Hebrew readers (Modern-Israeli phonemic):
53
+ *המחברות* ha-makhbarot ('the notebooks') → *מחברת* makhberet; *בספרו* bi-sfaro ('in his
54
+ book') → *ספר* sefer; and *הילדים שיחקו בגן* ha-yeladim sikhaku ba-gan ('the
55
+ children played in the garden') → *ילד* yeled, *שיחק* sikhek, *גן* gan.
56
+
57
+ `source` is `"retrieved"` when the lemma came from the bank and `"transduced"`
58
+ when the edit-script fallback produced it (this is how out-of-vocabulary words
59
+ are handled).
60
+
61
+ For information retrieval you usually drop closed-class function words (prepositions,
62
+ pronouns, conjunctions, …). Pass `blank_function_words=True` and those tokens come
63
+ back with an empty lemma and `source="function"`, so you can skip them:
64
+
65
+ ```python
66
+ lz = Lemmatizer.from_pretrained(blank_function_words=True)
67
+ ```
68
+
69
+ ### Whole documents and search indexing
70
+
71
+ `lemmatize_text(source)` is the call for **complete texts**. Give it a raw
72
+ string, a file path, or a folder path; it segments paragraphs → sentences →
73
+ word tokens and returns a **doc dict**:
74
+
75
+ ```python
76
+ doc = lz.lemmatize_text("הילדים שיחקו בגן. הם בנו ארמון בחול.")
77
+
78
+ doc["analyzed_text"] # the lemmas in order, as one string
79
+ doc["tokens"][0] # one record per word (shape):
80
+ # {'token': 'הילדים', 'start': 0, 'end': 6, 'lemma': 'ילד',
81
+ # 'pos': 'NOUN', 'source': 'bypass', 'score': ..., 'sent_id': 0}
82
+ ```
83
+
84
+ The doc dict has five keys:
85
+
86
+ | key | what it holds |
87
+ |---|---|
88
+ | `text` | the original input, echoed back |
89
+ | `tokens` | one record per word: `token, start, end, lemma, pos, source, score, sent_id` |
90
+ | `analyzed_text` | the lemmas in order, space-joined into one string |
91
+ | `es_tokens` | an Elasticsearch [`_analyze`](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html)-style stream, ready to index |
92
+ | `unknown` | out-of-bank words worth annotating (deduped, with counts) |
93
+
94
+ **Character offsets round-trip.** Every token carries absolute offsets into
95
+ `text`, so `text[start:end] == token` — slice the original to highlight a hit
96
+ without re-tokenizing. The same holds for `annotate(sentence)`: each row's
97
+ `start` indexes the sentence you passed in, so
98
+ `sentence[start:start + len(form)] == form`.
99
+
100
+ **Ready for Elasticsearch.** Each `es_tokens` entry is a lemma with its source
101
+ span and position — `{token, start_offset, end_offset, position, type}` — the
102
+ same shape Elasticsearch's `_analyze` emits, so you can feed it straight into an
103
+ index and search by lemma while highlighting the original surface text.
104
+
105
+ **`source` tells you where each lemma came from:**
106
+
107
+ - `retrieved` — pulled from the lemma bank,
108
+ - `transduced` — produced by the edit-script fallback (out-of-vocabulary words),
109
+ - `bypass` — retrieved from the bank with the coverage gate **skipped**, because the
110
+ form is a pronoun or one of a list of high-frequency closed-class words whose lemma
111
+ the gate reliably distrusts for the wrong reason. This is common: on ordinary text it
112
+ is close to half of all tokens,
113
+ - `acronym` — a known Hebrew acronym, kept as its own lemma with the coverage gate
114
+ skipped (`צה"ל` → `צה"ל`),
115
+ - `suppletive` — a curated look-up for irregular / closed-class forms whose
116
+ lemma shares too few letters to retrieve (e.g. *היא* → *הוא*),
117
+
118
+ > **Lemma convention for pronouns.** Following the MILA canonical-lemma
119
+ > convention, **personal pronouns lemmatize to הוא** — not only *היא*, *הם*
120
+ > and *הן*, but also *אני*, *אתה* and *אנחנו*. This is deliberate, not a bug:
121
+ > the pronoun paradigm is treated as one lexeme with הוא as its citation
122
+ > form. If you need person or number, read them from the surface form or
123
+ > from `pos`, not from the lemma.
124
+ >
125
+ > The look-up is keyed on the surface form **and the predicted POS**, so it
126
+ > applies when the word is actually tagged as a pronoun. In a sentence where
127
+ > the POS head mis-tags one — ill-formed agreement, say — the form comes back
128
+ > unchanged instead. Do not rely on the mapping as an invariant over arbitrary
129
+ > text; check `pos` alongside it.
130
+ - `function` — only with `blank_function_words=True`: closed-class stopwords
131
+ come back with an empty lemma and are kept in `tokens` (for provenance) but
132
+ dropped from `es_tokens` and `analyzed_text`.
133
+
134
+ **Files and folders.** Pass a file path and you get one doc dict back with a
135
+ `path` key added; pass a folder and you get `{relative_path: doc dict}` for
136
+ every text file under it (`files_glob="*.txt"`, recursive by default):
137
+
138
+ ```python
139
+ doc = lz.lemmatize_text("notes.txt") # one doc dict, plus doc["path"]
140
+ docs = lz.lemmatize_text("corpus/") # {relative_path: doc dict}
141
+ ```
142
+
143
+ From the command line:
144
+
145
+ ```bash
146
+ shoshan "המורה חילקה את המחברות לתלמידים בכיתה"
147
+ shoshan --csv input.csv output.csv # input columns: form,sentence[,pos]
148
+ ```
149
+
150
+ ## How it works
151
+
152
+ One DictaBERT encoder produces a vector for the target word in its sentence.
153
+ Three things hang off that vector:
154
+
155
+ 1. **Retrieval** — cosine similarity against a bank of ~118k pre-encoded lemmas.
156
+ 2. **A coverage gate** — checks whether the retrieved lemma's characters actually
157
+ appear, in order, inside the surface word. If they do not, the retrieval is
158
+ probably wrong (an unknown word), so the router does not trust it.
159
+ 3. **An edit-script head** — a learned, rule-free transformation of the *word*
160
+ into its lemma, used only when the gate distrusts retrieval.
161
+
162
+ Two routes skip step 2 deliberately, and between them they cover a large share of
163
+ ordinary text: a curated suppletive look-up and a known-acronym look-up answer directly
164
+ (`source` `suppletive` / `acronym`), and a list of pronouns and high-frequency
165
+ closed-class forms accepts retrieval without the coverage check (`source` `bypass`).
166
+ Those forms are exactly the ones whose lemma shares too few letters with the surface for
167
+ the gate to judge fairly. One consequence is worth knowing if you curate: `write_miss_log`
168
+ only ever sees tokens the gate actually judged, so a bypassed or suppletive token can
169
+ never appear in the worklist.
170
+
171
+ Because step 3 can only delete from or affix to the input word, the system has a
172
+ bounded output: it never produces an unrelated string. Adding vocabulary is just
173
+ adding rows to the bank and re-encoding them — no retraining.
174
+
175
+ ### Finding words to curate
176
+
177
+ The system tells you where it is unsure. A token is flagged when the bank's best lemma is
178
+ morphologically implausible for the surface form (the coverage gate distrusts the
179
+ retrieval) — i.e. a likely out-of-vocabulary word. Turn on `log_misses` and the model
180
+ collects these; `write_miss_log` writes them as a **frequency-sorted worklist** — the
181
+ word-forms most worth annotating or adding to the lexicon, commonest first.
182
+
183
+ ```python
184
+ lz = Lemmatizer.from_pretrained(log_misses=True)
185
+ lz.lemmatize(corpus_rows) # run over your text
186
+ lz.write_miss_log("to_curate.csv") # see the columns below
187
+ ```
188
+
189
+ or from the command line:
190
+
191
+ ```bash
192
+ shoshan --csv corpus.csv out.csv --miss-log to_curate.csv
193
+ ```
194
+
195
+ The worklist has eight columns, one row per (wordform, predicted POS) pair:
196
+
197
+ | column | what it holds |
198
+ |---|---|
199
+ | `wordform` | the surface form, as it appeared in your text |
200
+ | `predicted_pos` | the POS the model assigned it |
201
+ | `count` | how often the pair occurred; the file is sorted by this, commonest first |
202
+ | `predicted_lemma` | the lemma the system finally returned |
203
+ | `retrieved_lemma` | the bank's best candidate, which the coverage gate distrusted |
204
+ | `mean_coverage` | mean coverage-gate score over the occurrences |
205
+ | `mean_sim` | mean retrieval similarity over the occurrences |
206
+ | `reason` | why it was flagged (the commonest reason, when they differ) |
207
+
208
+ Since extending the system is just adding lemmas to the bank and re-encoding (no
209
+ retraining), this closes the loop: the model surfaces its own gaps, you curate the
210
+ highest-frequency ones, and coverage improves without a training step.
211
+
212
+ ## Results
213
+
214
+ The released model is trained only on the openly redistributable **Knesset +
215
+ Wikipedia** portions of the IAHLT Hebrew UD treebank, plus public lexicons. It is
216
+ evaluated on held-out domains it never saw in training.
217
+
218
+ **Exact-match lemma accuracy** (Shoshan):
219
+
220
+ | split | overall | seen form | unseen form |
221
+ |---|---|---|---|
222
+ | in-domain | 94.3% | 95.4% | 85.6% |
223
+ | out-of-domain | 92.4% | 95.1% | 81.2% |
224
+
225
+ Accuracy on words whose surface form was seen in training transfers almost intact
226
+ across domains (~95%); the gap sits in the genuinely unseen tail, which is exactly
227
+ where the edit-script transducer operates.
228
+
229
+ **Versus DictaBERT-lex** (out-of-domain). Exact match is not a fair cross-system
230
+ metric here: the two systems follow different lemma conventions (article stripping,
231
+ multi-word handling), so scoring one against the other's gold understates it. We
232
+ compare instead on convention-invariant **B³ consistency** (do inflections of a
233
+ word cluster together?) and on **low-overlap errors** (predictions that share too
234
+ little with the input word to be one of its forms).
235
+
236
+ | metric (out-of-domain) | Shoshan | DictaBERT-lex |
237
+ |---|---|---|
238
+ | B³ precision | **0.965** | 0.906 |
239
+ | B³ recall | **0.953** | 0.932 |
240
+ | B³ F1 | **0.959** | 0.919 |
241
+ | Low-overlap errors, unseen words | **0.0%** | 12.3% |
242
+
243
+ The last row is the point of the design: on words neither system saw in training,
244
+ DictaBERT-lex — which predicts each lemma as a single token from its vocabulary —
245
+ rewrites about one in eight into a word the input could not have produced (or emits
246
+ an empty token), while Shoshan's bounded output rules that out.
247
+ DictaBERT-lex was trained on more data than we use here, including the domains we
248
+ hold out, so the comparison is conservative.
249
+
250
+ The training data and evaluation splits live in a companion dataset,
251
+ [`HebArabNlpProject/shoshan-data`](https://huggingface.co/datasets/HebArabNlpProject/shoshan-data), and
252
+ download on demand:
253
+
254
+ ```python
255
+ from shoshan import data
256
+ df = data.load("ood") # or "train" / "dev" / "test" / "oov" / "ood_bagatz" ...
257
+ ```
258
+
259
+ Numbers and the full write-up are in the paper (in preparation).
260
+
261
+ ### Consistency over canonical form
262
+
263
+ For information retrieval the exact lemma string barely matters. What matters is that
264
+ every inflection of a word lands on the *same* label and that different words stay apart.
265
+ That is what B³ measures, and it is why we report it — and why it is the fair way to compare
266
+ against a system that uses a different lemma convention. Surfaces that look nothing alike
267
+ collapse to one label:
268
+
269
+ ```
270
+ הורדתי · מוריד · הורידו · יוריד → הוריד
271
+ ```
272
+
273
+ (*horadeti · morid · horidu · yorid → horid* — four very different surface forms of
274
+ "download / take down", all conflated to one lemma.)
275
+
276
+ B³ is blind to the label itself: a system that consistently used a *non-standard* string for
277
+ this cluster would score exactly the same. In that sense the value is stemmer-like — what the
278
+ label is called does not matter, only that every form of a word reaches the same one. What B³
279
+ penalizes is the reverse: *splitting* one word across several labels. In this system that
280
+ happens only at the edit-script fallback, on a rare unseen form it cannot retrieve — it may
281
+ guess a stem that does not match the rest of the lexeme. That residue is why B³ recall is
282
+ 0.953 and not 1.0, and richer context shrinks it by routing more forms back to clean
283
+ retrieval.
284
+
285
+ ## About the name
286
+
287
+ *Shoshan* is for Even-Shoshan, the Hebrew dictionary, since a lemmatizer's whole
288
+ job is to hand you the dictionary entry behind an inflected word. It is also a
289
+ lily.
290
+
291
+ ## License and credit
292
+
293
+ Code is MIT (`LICENSE`). The weights and data carry their own terms, recorded in
294
+ `NOTICE`: the encoder is fine-tuned from
295
+ [DictaBERT](https://huggingface.co/dicta-il/dictabert), and the data comes from the
296
+ IAHLT Hebrew UD treebank and public Hebrew lexicons; see `docs/DATA_STATEMENT.md`.
297
+ We thank **Avner Algom** and the **IAHLT** for the treebank data and for
298
+ permission to release the out-of-domain evaluation sentences.