Trains tagger, morphologizer, trainable_lemmatizer, parser and ner against one fine-tuned HooshvareLab/bert-base-parsbert-uncased through TransformerListener, rather than the sm/md/lg split where ner is trained separately and sourced in. Fine-tuning a 162M-parameter encoder twice would double GPU cost, ship two encoders in one wheel, and collide on the `transformer` component name. A shared encoder needs one corpus carrying both annotation layers, so merge_joint_corpus.py fuses them. corpus/perdt-ner/ came from the same --merge-subtokens CoNLL-U as corpus/merged/ with the same --n-sents, so the DocBins are token-for-token identical; the script asserts that per document and copies doc.ents by token index. Char offsets do not work here because the two converters differ in trailing whitespace, which pushes char_span off the token grid and returns None. Trained on a Colab T4 in 1h58m, 3000 steps, no early stop. Test scores against lg: DEP_LAS 90.79 (+4.19), ENTS_F 82.89 (+6.95, almost all recall), TAG_ACC 97.62 (+1.07). DEP_LAS passes the hazm+ParsBERT reference of 89.34, which no CPU tier reached. LEMMA_ACC 97.31 and SENTS_F 97.35 regress against lg; the likely cause of the latter is strided_spans leaving only 32 tokens of overlap. max_steps and learn_rate.total_steps are held equal on purpose. They are independent knobs, and a patience stop under a longer total_steps ends training at a high learning rate, discarding the annealing tail. finalize_pipeline.py now reads the encoder name out of the trained config instead of hardcoding it, tracks per-encoder licences, and writes a redistribution warning into meta.json when the encoder states none. ParsBERT states none, so that wheel is not redistributable; roberta-fa-zwnj-base (Apache-2.0) is a one-line change to `name`. Add benchmark_throughput.py, which times nlp.pipe alone. The words/s from `spacy benchmark accuracy` includes the Scorer's per-token alignment, which is why MODELS.md 7 reported ent_lg as faster than sm despite an identical ner architecture. Remeasured every tier on CPU and the 940MX; a background rsync halved every figure, so the final numbers are 9-run medians on an idle machine. Add make_model_card.py, which composes the Hub card from meta.json and the throughput records. The card spacy package writes has no frontmatter, so the Hub cannot index the model by language, and no install line or usage. |
||
|---|---|---|
| configs | ||
| docs | ||
| scripts | ||
| .gitignore | ||
| LICENSE | ||
| README.fa.md | ||
| README.md | ||
| project.yml | ||
| requirements.txt | ||
README.md
Persian (Farsi) pipelines for spaCy
Trained spaCy pipelines for Persian, installable now. spaCy has never shipped an official one, and spacy.blank("fa") only gives you a tokenizer and stop words. Choose between fa_core_news_sm (full syntax + NER) or fa_dep_news_sm (syntax only).
pip install https://huggingface.co/Phazel/fa_core_news_sm/resolve/main/fa_core_news_sm-3.8.0-py3-none-any.whl
>>> import spacy
>>> nlp = spacy.load("fa_core_news_sm")
>>> doc = nlp("محمدرضا شجریان در مشهد به دنیا آمد.")
>>> [(t.text, t.pos_, t.lemma_, t.dep_) for t in doc][:2]
[('محمدرضا', 'PROPN', 'محمدرضا', 'nsubj'), ('شجریان', 'PROPN', 'شجریان', 'flat:name')]
>>> doc.ents
(محمدرضا شجریان, مشهد)
>>> doc = nlp("شرکت ایران خودرو تولید را ۲۰ درصد افزایش میدهد.")
>>> [(e.text, e.label_) for e in doc.ents]
[('ایران خودرو', 'ORG'), ('۲۰ درصد', 'PCT')]
Results
Compared against Hazm (the most-used Persian toolkit) and en_core_web_sm (English reference).
| Metric | spacy-persianfa_core_news_sm |
Hazm (Persian toolkit) |
en_core_web_sm(English reference) |
|---|---|---|---|
| POS Accuracy (UPOS) | 96.24% | ~95.69%¹ | 97.21%² |
| Lemma Accuracy | 97.91% | 89.9%¹ | — |
| Dependency LAS | 85.15% | 85.6%¹ | 91.85%² |
| NER F-score | 71.87% | — | 83.80%² |
| Package Size | 13 MB (syntax+NER) 7.5 MB (syntax-only) |
~7 MB | 12 MB |
¹ Hazm scores from its official README ²
en_core_web_smscores from spaCy's official model card
Note on comparability: These benchmarks come from different evaluation sets, treebanks, and test splits.
From spacy benchmark accuracy, stored in metrics/.
| Package | Components | Licence | Score | Wheel |
|---|---|---|---|---|
fa_dep_news_sm |
tok2vec, tagger, morphologizer, trainable_lemmatizer, parser | CC BY-SA 4.0 | LEMMA 97.91 | 7.5 MB |
fa_core_news_sm |
the above plus ner | CC BY-SA 4.0 | ENTS_F 71.87 | 13 MB |
fa_ent_news_sm |
ner alone (own embedded tok2vec) |
CC BY-SA 4.0 | ENTS_F 71.87 | 5.6 MB |
fa_dep_news_md |
same as fa_dep_news_sm, plus floret vectors |
CC BY-SA 4.0 | LEMMA 97.96 | 62 MB |
fa_core_news_md |
same as fa_core_news_sm, plus floret vectors |
CC BY-SA 4.0 | ENTS_F 74.71 | 68 MB |
fa_ent_news_md |
ner alone (own embedded tok2vec), plus floret vectors |
CC BY-SA 4.0 | ENTS_F 74.71 | 58 MB |
The md tier adds a 50k x 300d floret vector table trained on 400k Persian documents. Its
config differs from sm by exactly one line (include_static_vectors), so the columns below
isolate what the vectors buy. Full breakdown in docs/MODELS.md §6.
| Metric | sm |
md |
lg |
trf |
Reference |
|---|---|---|---|---|---|
TOKEN_ACC / TOKEN_F |
99.96 / 99.11 | 99.96 / 99.11 | 99.96 / 99.11 | 99.96 / 99.11 | |
TAG_ACC (XPOS) |
95.96 | 96.25 | 96.55 | 97.62 | |
POS_ACC (UPOS) |
96.24 | 96.64 | 96.68 | 97.63 | |
MORPH_ACC |
96.29 | 96.64 | 96.70 | 97.82 | |
LEMMA_ACC |
97.91 | 97.96 | 98.08 | 97.31 | |
SENTS_F |
99.25 | 99.28 | 99.18 | 97.35 | |
DEP_UAS |
89.69 | 90.52 | 90.96 | 93.87 | hazm+ParsBERT: 92.46 |
DEP_LAS |
85.15 | 86.34 | 86.60 | 90.79 | hazm+ParsBERT: 89.34 |
ENTS_P |
77.67 | 76.56 | 81.51 | 84.06 | |
ENTS_R |
66.87 | 72.95 | 71.09 | 81.76 | |
ENTS_F |
71.87 | 74.71 | 75.94 | 82.89 | |
| Speed (940MX, batch 32) | 10,235 words/s | 9,058 words/s | 9,215 words/s | see §Throughput | |
| Wheel size | 13.5 MB | 68.5 MB | 235 MB | 608 MB |
trf fine-tunes ParsBERT and wins everywhere except lemmatization and sentence
segmentation, where lg's edit-tree lemmatizer over floret subwords still leads. It is the
only tier to pass the hazm+ParsBERT DEP_LAS reference of 89.34. It needs a GPU and its
encoder has no stated licence, so it is not redistributable; docs/MODELS.md §8 has both
caveats.
Entity scores are fa_core_news_* on the PerDT NER test split; per-label breakdown and
caveats are in Named entity recognition.
For comparison, en_core_web_sm scores TAG 97, LAS 90, ENTS_F 84 on a larger, cleaner corpus.
Trained on a 4-core i5-7200U with no GPU: sm 1h27m syntax + 17 min NER, md 1h54m syntax
- 25 min NER (the two
mdruns overlapped, so wall clock overstates each).
Throughput
Median of repeated nlp.pipe passes over the 146-document PerDT test split (23,825 tokens),
timing the pipe only, warmup discarded. Reproduce with
python scripts/benchmark_throughput.py <model> --gpu-id <n>; raw records are in
metrics/throughput-*.json.
| Tier | CPU, i5-7200U | GPU, GeForce 940MX | GPU, Tesla T4 |
|---|---|---|---|
sm |
5,484 | 10,235 | |
md |
5,408 | 9,058 | |
lg |
4,715 | 9,215 | |
trf |
187 | 8,320 |
The trf tier is a different kind of thing: 187 words/s on the same laptop CPU that runs
sm at 5,484, so about 29x slower. On a T4 it reaches 8,320, and on that VM's own Xeon it
manages 336, a 25x GPU speedup. Treat GPU as a requirement rather than an optimization.
The 940MX cannot run trf at all, since current PyTorch wheels have dropped its sm_50
compute capability.
sm, md and lg are within about 15% of each other on CPU, which is smaller than the
gap in vector-table size suggests: the tok2vec is not the bottleneck, the parser and
lemmatizer are. Run-to-run spread on the laptop is roughly +/-10% depending on thermal
state, so treat differences under that as noise.
Named entity recognition
Seven labels: LOC, PER, ORG, DAT, MON, TIM, PCT. They come from PerDT's own
not-to-release/Dadegan with NER tag/ layer, transferred onto this pipeline's tokenization
by difflib at a 99.86% alignment rate; spans that could not be aligned exactly were dropped
rather than guessed (scripts/transfer_perdt_ner.py). That layer is silver: PerDT's README
states it was produced by the BERT-based Beheshti-NER tagger with manual corrections for
recall, so the ENTS_F numbers below partly reflect agreement with that tagger, not with
human annotation.
ner runs standalone with its own embedded tok2vec (fa_ent_news_sm, fa_ent_news_md), or
bundled into fa_core_news_sm/fa_core_news_md alongside the syntax pipeline.
| Label | Gold in test | sm F |
md F |
Train examples |
|---|---|---|---|---|
LOC |
273 | 80.24 | 84.05 | 4,954 |
PER |
297 | 65.29 | 68.18 | 4,847 |
ORG |
144 | 68.77 | 70.25 | 2,643 |
DAT |
69 | 74.45 | 76.19 | 1,323 |
MON |
10 | 73.68 | 84.21 | 205 |
TIM |
9 | 66.67 | 66.67 | 135 |
PCT |
4 | 57.14 | 33.33 | 121 |
MON, TIM and PCT have single-digit support in the test split, so their deltas are one
or two entities changing hands, not signal. PER, LOC and ORG carry the split and all
improve with floret vectors; the md gain over sm (ENTS_F 71.87 to 74.71) is almost
entirely recall (+6.08), the lexical prior static vectors give rare proper nouns that hash
embeddings never had.
Install
pip install https://huggingface.co/Phazel/fa_core_news_sm/resolve/main/fa_core_news_sm-3.8.0-py3-none-any.whl
# or, without NER:
pip install https://huggingface.co/Phazel/fa_dep_news_sm/resolve/main/fa_dep_news_sm-3.8.0-py3-none-any.whl
Caveats
- Some lemmas contain a space. Multiword tokens were merged, so
کتابهایشis one token taggedN_IANM_PR_JOPERwith lemmaکتاب او. This affects about 1.5% of tokens. doc.noun_chunksunder-fires.spacy/lang/fa/syntax_iterators.pyupstream matches ClearNLP labels that do not exist in Universal Dependencies. Patch indocs/upstream/fa-noun-chunks.md.
Build
Everything is reproducible from checksummed assets. Python 3.12:
python -m venv .venv
.venv/bin/python -m pip install -U pip
.venv/bin/python -m pip install "spacy>=3.8,<3.9" spacy-lookups-data
.venv/bin/python -m spacy project assets # download + checksum the corpora
.venv/bin/python -m spacy project run all # -> fa_dep_news_sm + fa_core_news_sm
.venv/bin/python -m spacy project run ent # -> fa_ent_news_sm, NER alone
| Command | What it does |
|---|---|
inspect |
annotation coverage of the treebanks (scripts/inspect_treebanks.py) |
convert-ud |
CoNLL-U to DocBin with --merge-subtokens, plus the tokenizer-agreement report |
transfer-ner |
align PerDT's NER layer onto that tokenization by difflib (scripts/transfer_perdt_ner.py) |
convert-ner |
transferred IOB2 to DocBin |
debug-data, debug-data-ner |
spacy debug data on both corpora before spending CPU |
train-dep |
tagger + morphologizer + trainable_lemmatizer + parser |
train-ner |
the ner component, with its own embedded tok2vec |
finalize-dep |
write fa_dep_news_sm metadata: sources, licence, notes (scripts/finalize_pipeline.py) |
evaluate-dep |
spacy benchmark accuracy on the held-out UD test split |
assemble-core |
source ner into the dep pipeline to produce fa_core_news_sm |
evaluate-core |
score the assembled pipeline on both test splits |
finalize-meta |
re-run finalize on both, folding test scores into meta.json["performance"] |
package |
build wheels + sdists for both |
smoke |
run both pipelines over Persian text and print every annotation layer |
The two training runs are single-threaded and independent, so they can run concurrently.
Design decisions
--merge-subtokens. spaCy has no multiword-token layer, and PerDT splits pronominal clitics (پدرمintoپدر+م). Measured on dev, merging gives token F 0.9887 against 0.9823 for the split version, costing 34 composite XPOS tags on 1.5% of tokens. Without it, 1.5% of gold tokens are boundaries the shipped tokenizer can never produce. Seescripts/tokenization_report.py.nercarries its own tok2vec. ATok2VecListeneronly resolves inside the pipeline it was trained in, so a listener-based component cannot be sourced elsewhere.configs/fa_ner_sm.cfgembeds the tok2vec instead, asen_core_web_smdoes.morphologizer+trainable_lemmatizerinstead ofattribute_ruler+ rule lemmatizer. The English pipelines derive UPOS from PTB tags by rule because OntoNotes has no UPOS. UD gives gold UPOS, FEATS and lemmas, which yields realpos_acc,morph_accandlemma_accnumbers instead of unmeasurable rule coverage.- PerDT, not Seraji: 3.7x more tokens, and Seraji has no
PROPNtag.
Why not hazm's own models
hazm is the reference Persian NLP toolkit and publishes spaCy-format pipelines on the HF Hub, so it was the obvious starting point. Four problems:
- Its trainable models are pycrfsuite CRFs (
hazm/sequence_tagger.py). The repo contains noconfig.cfgand nospacy train; theSpacy*classes only download pretrained pipelines. - Those pipelines are three single-task models (
transformer + tagger,transformer + parser,transformer + chunker), eachversion: 0.0.0with an emptylicensefield, pinned to spaCy 3.6. Using all three costs three ParsBERT forward passes and gives no sharedDoc. - Its tokenizer is incompatible with UD tokenization: the normaliser fuses ZWNJ affixes and
join_verb_parts()glues multi-word verb chains into single tokens. - Most corpora it reads (Bijankhan, Peykare, Hamshahri, raw PerDT) sit behind
peykaregan.irordadegan.irunder research-only terms.
It did confirm the corpus choice. hazm's own spaCy parser was trained on
modified_fa_perdt-ud-train.spacy, the same treebank used here.
More
- Pipeline inventory, corpus and licence analysis:
docs/MODELS.md - How spaCy models get published, and what upstream
faalready has:docs/CONTRIBUTING-GUIDE.md - The build:
project.yml - خلاصهٔ فارسی:
README.fa.md - Language data comes from
spacy/lang/faupstream, whose stop word list came from hazm.