Skip to content

Commit 3a550f1

Browse files
committed
When reading in a conllu Document, put start/end offsets on the words and tokens, either by matching the text to the sentence.text or by deriving from the Space annotations on the words/tokens
1 parent c3e0b60 commit 3a550f1

2 files changed

Lines changed: 407 additions & 2 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""
2+
Tests of rebuilding the text and the character offsets of a document read from CoNLL-U
3+
"""
4+
5+
import warnings
6+
7+
import pytest
8+
9+
from stanza.utils.conll import CoNLL
10+
from stanza.tests import *
11+
12+
pytestmark = pytest.mark.pipeline
13+
14+
# an MWT which splits into pieces of the surface form, a couple SpaceAfter=No,
15+
# and a SpacesAfter at the end of the sentence
16+
ENGLISH = """
17+
# sent_id = 1
18+
# text = She can't swim, sadly.
19+
1\tShe\tshe\tPRON\tPRP\t_\t3\tnsubj\t_\t_
20+
2-3\tcan't\t_\t_\t_\t_\t_\t_\t_\t_
21+
2\tca\tcan\tAUX\tMD\t_\t4\taux\t_\t_
22+
3\tn't\tnot\tPART\tRB\t_\t4\tadvmod\t_\t_
23+
4\tswim\tswim\tVERB\tVB\t_\t0\troot\t_\tSpaceAfter=No
24+
5\t,\t,\tPUNCT\t,\t_\t6\tpunct\t_\t_
25+
6\tsadly\tsadly\tADV\tRB\t_\t4\tadvmod\t_\tSpaceAfter=No
26+
7\t.\t.\tPUNCT\t.\t_\t4\tpunct\t_\tSpacesAfter=\\n
27+
28+
# sent_id = 2
29+
# text = Bye!
30+
1\tBye\tbye\tINTJ\tUH\t_\t0\troot\t_\tSpaceAfter=No
31+
2\t!\t!\tPUNCT\t.\t_\t1\tpunct\t_\t_
32+
""".lstrip()
33+
34+
ENGLISH_TEXT = "She can't swim, sadly.\nBye!"
35+
36+
# `al` is an MWT which is not a substring split of its words
37+
SPANISH = """
38+
# sent_id = 1
39+
# text = Vamos al mar.
40+
1\tVamos\tir\tVERB\t_\t_\t0\troot\t_\t_
41+
2-3\tal\t_\t_\t_\t_\t_\t_\t_\t_
42+
2\ta\ta\tADP\t_\t_\t4\tcase\t_\t_
43+
3\tel\tel\tDET\t_\t_\t4\tdet\t_\t_
44+
4\tmar\tmar\tNOUN\t_\t_\t1\tobl\t_\tSpaceAfter=No
45+
5\t.\t.\tPUNCT\t_\t_\t1\tpunct\t_\t_
46+
""".lstrip()
47+
48+
# the text comment says there are spaces around the quotes,
49+
# the MISC says there are not
50+
INCONSISTENT = """
51+
# sent_id = 1
52+
# text = Il a dit « bonjour ».
53+
1\tIl\til\tPRON\t_\t_\t3\tnsubj\t_\t_
54+
2\ta\tavoir\tAUX\t_\t_\t3\taux\t_\t_
55+
3\tdit\tdire\tVERB\t_\t_\t0\troot\t_\t_
56+
4\t«\t«\tPUNCT\t_\t_\t6\tpunct\t_\tSpaceAfter=No
57+
5\tbonjour\tbonjour\tNOUN\t_\t_\t3\tobj\t_\tSpaceAfter=No
58+
6\t»\t»\tPUNCT\t_\t_\t5\tpunct\t_\tSpaceAfter=No
59+
7\t.\t.\tPUNCT\t_\t_\t3\tpunct\t_\t_
60+
""".lstrip()
61+
62+
# the SpaceAfter=No which matters here is on the range line of the MWT,
63+
# which is where UD puts it, rather than on the last word
64+
MWT_SPACE_AFTER = """
65+
# sent_id = 1
66+
# text = Nadie puede representarme.
67+
1\tNadie\tnadie\tPRON\t_\tNumber=Sing|PronType=Neg\t3\tnsubj\t_\t_
68+
2\tpuede\tpoder\tAUX\t_\tVerbForm=Fin\t3\taux\t_\t_
69+
3-4\trepresentarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No
70+
3\trepresentar\trepresentar\tVERB\t_\tVerbForm=Inf\t0\troot\t_\t_
71+
4\tme\tyo\tPRON\t_\tNumber=Sing|Person=1\t3\tobj\t_\t_
72+
5\t.\t.\tPUNCT\t_\tPunctType=Peri\t3\tpunct\t_\t_
73+
""".lstrip()
74+
75+
# the words of the MWT have their own MISC, but none of it is about
76+
# whitespace, so the SpaceAfter=No on the range line is still the one that counts
77+
MWT_UNRELATED_WORD_MISC = """
78+
# sent_id = 1
79+
# text = Nadie puede representarme.
80+
1\tNadie\tnadie\tPRON\t_\tNumber=Sing|PronType=Neg\t3\tnsubj\t_\t_
81+
2\tpuede\tpoder\tAUX\t_\tVerbForm=Fin\t3\taux\t_\t_
82+
3-4\trepresentarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No
83+
3\trepresentar\trepresentar\tVERB\t_\tVerbForm=Inf\t0\troot\t_\tNER=O
84+
4\tme\tyo\tPRON\t_\tNumber=Sing|Person=1\t3\tobj\t_\tNER=B-PER|Translit=me
85+
5\t.\t.\tPUNCT\t_\tPunctType=Peri\t3\tpunct\t_\t_
86+
""".lstrip()
87+
88+
def check_offsets(doc):
89+
"""Every token and every word with an offset should index back to itself"""
90+
for sentence in doc.sentences:
91+
for token in sentence.tokens:
92+
assert doc.text[token.start_char:token.end_char] == token.text
93+
for word in token.words:
94+
if word.start_char is not None:
95+
assert doc.text[word.start_char:word.end_char] == word.text
96+
97+
def test_no_offsets_by_default():
98+
"""Reading a document without the flag should not invent offsets"""
99+
doc = CoNLL.conll2doc(input_str=ENGLISH)
100+
assert doc.text is None
101+
for sentence in doc.sentences:
102+
for token in sentence.tokens:
103+
assert token.start_char is None
104+
assert token.end_char is None
105+
106+
def test_text_and_offsets():
107+
doc = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True)
108+
assert doc.text == ENGLISH_TEXT
109+
check_offsets(doc)
110+
tokens = doc.sentences[0].tokens
111+
assert (tokens[0].start_char, tokens[0].end_char) == (0, 3)
112+
# the MWT is split across its words
113+
assert [(word.start_char, word.end_char) for word in tokens[1].words] == [(4, 6), (6, 9)]
114+
115+
def test_sentence_text():
116+
doc = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True)
117+
assert [sentence.text for sentence in doc.sentences] == ["She can't swim, sadly.", "Bye!"]
118+
119+
def test_no_text_comments():
120+
"""The offsets should come out the same when rebuilt from SpaceAfter alone"""
121+
without = "\n".join(x for x in ENGLISH.split("\n") if not x.startswith("# text"))
122+
doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True)
123+
assert doc.text == ENGLISH_TEXT
124+
check_offsets(doc)
125+
126+
def test_unsplittable_mwt():
127+
"""`al` is not made of its words, so the words get no offsets, but the token does"""
128+
doc = CoNLL.conll2doc(input_str=SPANISH, reconstruct_text=True)
129+
assert doc.text == "Vamos al mar."
130+
check_offsets(doc)
131+
token = doc.sentences[0].tokens[1]
132+
assert (token.start_char, token.end_char) == (6, 8)
133+
assert all(word.start_char is None for word in token.words)
134+
135+
def test_inconsistent_annotation():
136+
"""When the text comment and the MISC disagree, the text wins, with a warning"""
137+
with pytest.warns(UserWarning):
138+
doc = CoNLL.conll2doc(input_str=INCONSISTENT, reconstruct_text=True)
139+
assert doc.text == "Il a dit « bonjour »."
140+
check_offsets(doc)
141+
142+
def test_mwt_space_after():
143+
"""UD marks SpaceAfter on the range line of an MWT, not on its last word"""
144+
doc = CoNLL.conll2doc(input_str=MWT_SPACE_AFTER, reconstruct_text=True)
145+
assert doc.text == "Nadie puede representarme."
146+
check_offsets(doc)
147+
148+
def test_mwt_space_after_no_text_comment():
149+
"""The same, with only the SpaceAfter annotations to go on"""
150+
without = "\n".join(x for x in MWT_SPACE_AFTER.split("\n") if not x.startswith("# text"))
151+
doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True)
152+
assert doc.text == "Nadie puede representarme."
153+
check_offsets(doc)
154+
155+
def test_mwt_space_after_on_word():
156+
"""Some treebanks put the annotation on the last word instead"""
157+
moved = MWT_SPACE_AFTER.replace("representarme\t_\t_\t_\t_\t_\t_\t_\tSpaceAfter=No", "representarme\t_\t_\t_\t_\t_\t_\t_\t_")
158+
moved = moved.replace("Number=Sing|Person=1\t3\tobj\t_\t_", "Number=Sing|Person=1\t3\tobj\t_\tSpaceAfter=No")
159+
without = "\n".join(x for x in moved.split("\n") if not x.startswith("# text"))
160+
doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True)
161+
assert doc.text == "Nadie puede representarme."
162+
check_offsets(doc)
163+
164+
def test_unrelated_word_misc():
165+
"""MISC on a word which says nothing about whitespace should not mask the token's annotation
166+
167+
Without the `# text` comment there is nothing else to fall back on, so
168+
reading the word's MISC as "no annotation means a space" would put a
169+
space in front of the period
170+
"""
171+
without = "\n".join(x for x in MWT_UNRELATED_WORD_MISC.split("\n") if not x.startswith("# text"))
172+
doc = CoNLL.conll2doc(input_str=without, reconstruct_text=True)
173+
assert doc.text == "Nadie puede representarme."
174+
check_offsets(doc)
175+
176+
def test_unrelated_word_misc_no_warning():
177+
"""The same document with its `# text` comment should agree, and so not warn"""
178+
with warnings.catch_warnings():
179+
warnings.simplefilter("error")
180+
doc = CoNLL.conll2doc(input_str=MWT_UNRELATED_WORD_MISC, reconstruct_text=True)
181+
assert doc.text == "Nadie puede representarme."
182+
check_offsets(doc)
183+
184+
def test_misc_unchanged():
185+
"""Rebuilding the text should not rewrite the MISC column"""
186+
plain = CoNLL.conll2doc(input_str=ENGLISH)
187+
offsets = CoNLL.conll2doc(input_str=ENGLISH, reconstruct_text=True)
188+
assert "{:C-o}".format(plain) == "{:C-o}".format(offsets)

0 commit comments

Comments
 (0)