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.
- shoshan-0.4.0/LICENSE +21 -0
- shoshan-0.4.0/NOTICE +27 -0
- shoshan-0.4.0/PKG-INFO +322 -0
- shoshan-0.4.0/README.md +298 -0
- shoshan-0.4.0/pyproject.toml +39 -0
- shoshan-0.4.0/setup.cfg +4 -0
- shoshan-0.4.0/src/shoshan/__init__.py +12 -0
- shoshan-0.4.0/src/shoshan/cli.py +62 -0
- shoshan-0.4.0/src/shoshan/data/coverage_bypass_forms.txt +3090 -0
- shoshan-0.4.0/src/shoshan/data/suppletives.csv +347 -0
- shoshan-0.4.0/src/shoshan/data/wiktionary_acronyms.csv +3429 -0
- shoshan-0.4.0/src/shoshan/data.py +41 -0
- shoshan-0.4.0/src/shoshan/doc_text.py +133 -0
- shoshan-0.4.0/src/shoshan/edit_script.py +116 -0
- shoshan-0.4.0/src/shoshan/hub.py +25 -0
- shoshan-0.4.0/src/shoshan/infer.py +544 -0
- shoshan-0.4.0/src/shoshan/lemma_bank.py +138 -0
- shoshan-0.4.0/src/shoshan/model_joint.py +153 -0
- shoshan-0.4.0/src/shoshan/normalize.py +165 -0
- shoshan-0.4.0/src/shoshan/runtime.py +62 -0
- shoshan-0.4.0/src/shoshan/suppletive.py +58 -0
- shoshan-0.4.0/src/shoshan/text.py +168 -0
- shoshan-0.4.0/src/shoshan.egg-info/PKG-INFO +322 -0
- shoshan-0.4.0/src/shoshan.egg-info/SOURCES.txt +35 -0
- shoshan-0.4.0/src/shoshan.egg-info/dependency_links.txt +1 -0
- shoshan-0.4.0/src/shoshan.egg-info/entry_points.txt +2 -0
- shoshan-0.4.0/src/shoshan.egg-info/requires.txt +5 -0
- shoshan-0.4.0/src/shoshan.egg-info/top_level.txt +1 -0
- shoshan-0.4.0/tests/test_annotate_offsets.py +109 -0
- shoshan-0.4.0/tests/test_api_contracts.py +126 -0
- shoshan-0.4.0/tests/test_drifting_corpus.py +146 -0
- shoshan-0.4.0/tests/test_encode_once.py +85 -0
- shoshan-0.4.0/tests/test_presentation_fold.py +154 -0
- shoshan-0.4.0/tests/test_source_is_nfc_stable.py +69 -0
- shoshan-0.4.0/tests/test_span_offsets.py +183 -0
- shoshan-0.4.0/tests/test_spans.py +342 -0
- 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.
|
shoshan-0.4.0/README.md
ADDED
|
@@ -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.
|