spacy-fa-pipeline/docs/upstream/fa-noun-chunks.md

224 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Upstream PR candidate: `spacy/lang/fa/syntax_iterators.py` uses non-UD dependency labels
## The bug
`spacy/lang/fa/syntax_iterators.py` matches these dependency labels as noun-phrase heads:
```python
labels = ["nsubj", "dobj", "nsubjpass", "pcomp", "pobj", "dative", "appos", "attr", "ROOT"]
```
`dobj`, `nsubjpass`, `pobj`, `dative` and `attr` are ClearNLP/English labels. They are not
Universal Dependencies relations, and **every Persian treebank is UD** (`UD_Persian-PerDT`,
`UD_Persian-Seraji`, `UD_Persian-PUD`, `UD_Persian-IPerUDT`). Any trained `fa` pipeline must
therefore emit UD labels, so five of the nine labels are dead code and `doc.noun_chunks`
silently returns bare head nouns.
Compare `spacy/lang/fr/syntax_iterators.py` and `spacy/lang/es/syntax_iterators.py`, which
use UD labels (`nsubj`, `nsubj:pass`, `obj`, `obl`, `nmod`, `appos`, `ROOT`) because their
treebanks are UD too. `spacy/lang/de` legitimately uses TIGER labels (`sb`, `oa`, `nk`, …)
because the German pipelines are trained on TIGER. Persian has no such excuse.
Second, smaller problem: the iterator yields `word.left_edge.i``word.i + 1`, i.e. it only
ever expands **left**. Persian noun phrases expand **right** through ezafe:
`رئیس انجمن جراحان قلب ایران` ("the head of the Iranian society of heart surgeons") is one NP
whose head is the leftmost token. Left-only expansion truncates it to `رئیس`.
## Evidence
Measured with `scripts/check_noun_chunks.py` on the `UD_Persian-PerDT` dev split (146 docs)
parsed by this project's trained pipeline:
```
shipped noun_chunks: 1503 chunks, 1.31 tokens/chunk
proposed noun_chunks: 5300 chunks, 2.77 tokens/chunk
deprels on NOUN/PROPN/PRON tokens in dev (top 15):
nmod 2894 -
obl 1401 -
nsubj 1326 shipped
compound:lvc 1294 -
obl:arg 1095 -
obj 973 -
conj 583 shipped
flat:name 389 -
ROOT 101 shipped
xcomp 89 -
appos 51 shipped
nsubj:pass 38 -
```
1.31 tokens per chunk is the tell: the shipped iterator is returning single head nouns.
`nmod` (2894), `obl` (1401), `obl:arg` (1095) and `obj` (973) — the four most common
noun-bearing relations after `nsubj` — are all unreachable.
Live text:
```
رئیس انجمن جراحان قلب ایران تأکید کرد که امکانات پیشرفته در ایران وجود دارد.
shipped : ['رئیس', 'امکانات']
proposed: ['رئیس انجمن جراحان قلب ایران', 'امکانات پیشرفته', 'ایران']
دانشگاه تهران بزرگ‌ترین دانشگاه ایران است.
shipped : ['دانشگاه']
proposed: ['دانشگاه تهران', 'ایران']
```
## Proposed patch
Swap in UD labels, expand right through modifier chains, and drop a leading `ADP`/`CCONJ`
exactly as `fr`/`es` already do:
```diff
--- a/spacy/lang/fa/syntax_iterators.py
+++ b/spacy/lang/fa/syntax_iterators.py
@@
def noun_chunks(doclike: Union[Doc, Span]) -> Iterator[Tuple[int, int, int]]:
"""
Detect base noun phrases from a dependency parse. Works on both Doc and Span.
"""
- labels = [
- "nsubj",
- "dobj",
- "nsubjpass",
- "pcomp",
- "pobj",
- "dative",
- "appos",
- "attr",
- "ROOT",
- ]
+ # Persian pipelines are trained on Universal Dependencies treebanks
+ # (UD_Persian-PerDT, UD_Persian-Seraji), so these are UD relations.
+ labels = [
+ "nsubj",
+ "nsubj:pass",
+ "obj",
+ "iobj",
+ "obl",
+ "obl:arg",
+ "nmod",
+ "appos",
+ "vocative",
+ "ROOT",
+ ]
+ # Persian noun phrases grow to the right through ezafe constructions.
+ post_modifiers = [
+ "nmod",
+ "nmod:poss",
+ "amod",
+ "det",
+ "nummod",
+ "flat",
+ "flat:name",
+ "flat:num",
+ "fixed",
+ "compound",
+ ]
doc = doclike.doc # Ensure works on both Doc and Span.
if not doc.has_annotation("DEP"):
raise ValueError(Errors.E029)
np_deps = [doc.vocab.strings.add(label) for label in labels]
+ np_modifs = {doc.vocab.strings.add(label) for label in post_modifiers}
conj = doc.vocab.strings.add("conj")
np_label = doc.vocab.strings.add("NP")
+ adp_pos = doc.vocab.strings.add("ADP")
+ cconj_pos = doc.vocab.strings.add("CCONJ")
prev_end = -1
for i, word in enumerate(doclike):
if word.pos not in (NOUN, PROPN, PRON):
continue
# Prevent nested chunks from being produced
if word.left_edge.i <= prev_end:
continue
- if word.dep in np_deps:
- prev_end = word.i
- yield word.left_edge.i, word.i + 1, np_label
- elif word.dep == conj:
+ if word.dep == conj:
head = word.head
while head.dep == conj and head.head.i < head.i:
head = head.head
- # If the head is an NP, and we're coordinated to it, we're an NP
- if head.dep in np_deps:
- prev_end = word.i
- yield word.left_edge.i, word.i + 1, np_label
+ # If the head is an NP, and we're coordinated to it, we're an NP
+ if head.dep not in np_deps:
+ continue
+ elif word.dep not in np_deps:
+ continue
+ # Expand right through the ezafe / modifier chain.
+ right = word
+ for child in word.rights:
+ if child.dep in np_modifs:
+ right = child.right_edge
+ else:
+ break
+ start, end = word.left_edge.i, max(word.i, right.i) + 1
+ # A leading preposition or coordinator is not part of the NP.
+ while start < word.i and doc[start].pos in (adp_pos, cconj_pos):
+ start += 1
+ if end <= prev_end:
+ continue
+ prev_end = end - 1
+ yield start, end, np_label
```
The working implementation lives in `scripts/check_noun_chunks.py::proposed_noun_chunks`.
## Test to add
`spacy/tests/lang/fa/test_noun_chunks.py` currently has one test (a hand-built `Doc`, 296 B).
Add UD-label coverage:
```python
def test_fa_noun_chunks_ezafe(fa_vocab):
# رئیس انجمن جراحان — "head of the surgeons' society"
words = ["رئیس", "انجمن", "جراحان", "آمد"]
heads = [3, 0, 1, 3]
deps = ["nsubj", "nmod", "nmod", "ROOT"]
pos = ["NOUN", "NOUN", "NOUN", "VERB"]
doc = Doc(fa_vocab, words=words, heads=heads, deps=deps, pos=pos)
assert [c.text for c in doc.noun_chunks] == ["رئیس انجمن جراحان"]
def test_fa_noun_chunks_drops_leading_adp(fa_vocab):
words = ["در", "ایران", "بود"]
heads = [1, 2, 2]
deps = ["case", "obl", "ROOT"]
pos = ["ADP", "NOUN", "VERB"]
doc = Doc(fa_vocab, words=words, heads=heads, deps=deps, pos=pos)
assert [c.text for c in doc.noun_chunks] == ["ایران"]
```
## Other `spacy/lang/fa` gaps found while building this pipeline
Ordered by how much they cost a real Persian pipeline:
1. **Clitic splitting.** The `fa` tokenizer cannot split pronominal enclitics or the enclitic
copula (`پدرم` → `پدر` + `م`, `ساکتند``ساکت` + `ند`), which UD treebanks annotate as
multiword tokens. Measured on PerDT dev: gold-vs-tokenizer token F is 0.9823 when clitics
are kept split, and 1.49% of tokens are affected. Persian-specific `TOKENIZER_SUFFIXES`
entries for the enclitic set would remove the need for `--merge-subtokens` and eliminate
the composite XPOS tags it produces.
2. **`punctuation.py` defines only `TOKENIZER_SUFFIXES`** — no `TOKENIZER_PREFIXES`, no
`TOKENIZER_INFIXES`. ZWNJ (U+200C) is handled only implicitly through the 65 KB generated
verb-exception table. The missing infix rules bite on numerics: `spacy/lang/fa/examples.py`
ships the sentence `دیروز علی به من ۲۰۰۰.۱﷼ پول نقد داد.`, and the trained pipeline splits
`۲۰۰۰.۱﷼` into `۲۰۰۰` + `.` + `۱﷼`, with the stray `.` promoted to a sentence boundary —
one input sentence comes out as three. `LIKE_NUM` in `lex_attrs.py` recognises Persian
digits, but no tokenizer rule keeps a Persian decimal or a currency sign attached.
3. **No tokenizer tests at all** for `fa``spacy/tests/lang/fa/` contains only
`test_noun_chunks.py`, guarding none of the 65 KB exception table.
4. **`spacy-lookups-data` has no `fa_license.txt`**, although `fa_source.txt` records that the
lemma tables were "extracted from Mojgan Seraji's Persian Universal Dependencies Corpus" —
which is CC BY-SA 4.0. Catalan ships a `ca_license.txt`; Persian should too.
5. **No `fa_lemma_lookup.json`** — only rule-mode lemmatizer assets exist, so
`mode="lookup"` is unavailable for Persian.
Items 13 are self-contained code PRs. Item 4 is a licence-hygiene PR against
`spacy-lookups-data` and matters for anyone redistributing a Persian pipeline.