Skip to content

Commit 30df510

Browse files
committed
Dynamically removing initial punct from sentences in each of tokenizer, pos, depparse - lets us remove that from the dataset processing and lets the models train on all sentences with that executed dynamically.
Turns out the tokenizer treats sentences after the first sentence in a doc as starting with a space. The initial punct augmentation needs to respect that
1 parent 769b349 commit 30df510

13 files changed

Lines changed: 935 additions & 264 deletions

File tree

‎stanza/models/common/data.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,45 @@ def get_augment_ratio(train_data, should_augment_predicate, can_augment_predicat
8383
if ratio > max_ratio:
8484
return max_ratio
8585
return ratio
86+
87+
88+
# Spanish/Catalan-style inverted question and exclamation marks. Some UD
89+
# treebanks have every training sentence begin with one of these, which
90+
# teaches a model to always expect the mark and misparse/mistag/mistokenize
91+
# a sentence that's missing it. augment_initial_punct in
92+
# prepare_tokenizer_treebank.py handles this at dataset-preparation time
93+
# (currently for ¿ only); the POS tagger and dependency parser instead
94+
# apply the equivalent augmentation dynamically, per sentence, inside their
95+
# own Dataset.__getitem__ (see starts_with_initial_mark below). The
96+
# tokenizer needs its own character-level version of this same check
97+
# (see drop_initial_punct in stanza.models.tokenization.data), since it
98+
# operates on individual characters before word boundaries exist, but
99+
# imports this same tuple so the mark set itself is defined in one place.
100+
INITIAL_INVERTED_PUNCT_MARKS = ('¿', '¡')
101+
102+
def starts_with_initial_mark(words, marks=INITIAL_INVERTED_PUNCT_MARKS):
103+
"""
104+
True if the given list of word/token strings starts with one of the
105+
given marks, and no mark from the set (of any kind, not just the
106+
leading one) appears anywhere else in the list.
107+
108+
The restriction mirrors augment_initial_punct in
109+
prepare_tokenizer_treebank.py, and exists to avoid ambiguity with
110+
nested or quoted questions/exclamations -- e.g. a sentence like
111+
'¿Dijo "¡hola!"?' has two candidate marks (one ¿ and one ¡) and isn't
112+
a case this augmentation should touch, even though neither mark is
113+
individually repeated.
114+
115+
Shared by the POS tagger and dependency parser, whose sentences are
116+
both, at this point, plain lists of word strings -- the two models
117+
diverge only in how they physically drop the first word afterward
118+
(the parser also has to renumber head positions, which the tagger
119+
does not need to do).
120+
"""
121+
if len(words) <= 1:
122+
return False
123+
first = words[0]
124+
if first not in marks:
125+
return False
126+
total_marks = sum(1 for w in words if w in marks)
127+
return total_marks == 1

‎stanza/models/depparse/data.py‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from torch.utils.data.sampler import Sampler
88

99
from stanza.models.common.bert_embedding import filter_data, needs_length_filter
10-
from stanza.models.common.data import map_to_ids, get_long_tensor, get_float_tensor, sort_all, get_augment_ratio
10+
from stanza.models.common.data import map_to_ids, get_long_tensor, get_float_tensor, sort_all, get_augment_ratio, INITIAL_INVERTED_PUNCT_MARKS, starts_with_initial_mark
1111
from stanza.models.common.utils import DEFAULT_WORD_CUTOFF, simplify_punct
1212
from stanza.models.common.vocab import PAD_ID, VOCAB_PREFIX, ROOT_ID, CompositeVocab, CharVocab
1313
from stanza.models.pos.vocab import WordVocab, XPOSVocab, FeatureVocab, MultiVocab
@@ -104,6 +104,75 @@ def record_can_augment_nopunct(record, punct_id):
104104
return True
105105

106106

107+
def record_starts_with_mark(record, marks=INITIAL_INVERTED_PUNCT_MARKS):
108+
"""
109+
True if this preprocessed sentence's first real word is exactly one
110+
of the given marks (by default the Spanish/Catalan inverted question
111+
and exclamation marks), and no mark from the set appears anywhere
112+
else in the sentence.
113+
114+
Thin wrapper around stanza.models.common.data.starts_with_initial_mark
115+
-- record[9] is the plain-string text field (never vocab-mapped),
116+
which is exactly the "list of word strings" that function expects.
117+
The actual mark-matching logic (including the no-other-mark
118+
restriction, mirroring augment_initial_punct in
119+
prepare_tokenizer_treebank.py) lives there in one place, shared with
120+
the POS tagger's equivalent eligibility check; only this record[9]
121+
extraction is specific to the parser's own record shape.
122+
"""
123+
return starts_with_initial_mark(record[9], marks)
124+
125+
126+
def record_can_drop_initial_mark(record, marks=INITIAL_INVERTED_PUNCT_MARKS):
127+
"""
128+
True if the sentence starts with one of the given marks (see
129+
record_starts_with_mark) and no other word's head depends directly
130+
on that first token -- removing it would otherwise leave a word's
131+
head pointing at a position that no longer exists.
132+
133+
record[7] is the head field (no ROOT, one 1-indexed position per
134+
real word); the first real word's own 1-indexed position is always 1.
135+
"""
136+
if not record_starts_with_mark(record, marks):
137+
return False
138+
head = record[7]
139+
if any(h == 1 for h in head[1:]):
140+
return False
141+
return True
142+
143+
144+
def drop_initial_mark_from_record(record):
145+
"""
146+
Removes the first real word from a preprocessed sentence, renumbering
147+
every remaining word's head position down by one to account for it.
148+
149+
Unlike dropping the last word (record_can_augment_nopunct's case),
150+
dropping the FIRST word shifts every later word's 1-indexed position
151+
back by one, so every head value greater than 0 must be decremented;
152+
a head of 0 (root) is left untouched. record_can_drop_initial_mark
153+
already guarantees no remaining word's head is exactly 1 (i.e.
154+
nothing depends on the word being removed), so every nonzero head
155+
among the remaining words is guaranteed to be >= 2 before the shift.
156+
"""
157+
word, char, upos, xpos, feats, pretrain, lemma, head, deprel, text = record
158+
# ROOT-prepended fields: keep ROOT (index 0), drop the removed word
159+
# (index 1), keep everything after it unchanged
160+
new_word = [word[0]] + word[2:]
161+
new_char = [char[0]] + char[2:]
162+
new_upos = [upos[0]] + upos[2:]
163+
new_xpos = [xpos[0]] + xpos[2:]
164+
new_feats = [feats[0]] + feats[2:]
165+
new_pretrain = [pretrain[0]] + pretrain[2:]
166+
new_lemma = [lemma[0]] + lemma[2:]
167+
# non-ROOT-prepended fields: drop the removed word's own entry, and
168+
# shift every remaining head position down by one
169+
new_head = [h - 1 if h > 0 else h for h in head[1:]]
170+
new_deprel = deprel[1:]
171+
new_text = text[1:]
172+
return [new_word, new_char, new_upos, new_xpos, new_feats, new_pretrain,
173+
new_lemma, new_head, new_deprel, new_text]
174+
175+
107176
class Dataset:
108177
"""
109178
Sentence-level dataset for the dependency parser: owns vocab
@@ -187,6 +256,23 @@ def __init__(self, doc, args, pretrain, vocab=None, evaluation=False, bert_token
187256
else:
188257
self.augment_nopunct_ratio = augment_nopunct_arg
189258

259+
# dynamic leading-inverted-punct drop: some UD treebanks (Spanish,
260+
# Catalan) have every training sentence begin with an inverted
261+
# question or exclamation mark (¿/¡), which the model never learns
262+
# to do without. Mirrors augment_initial_punct in
263+
# prepare_tokenizer_treebank.py, applied per sentence in
264+
# __getitem__ instead of by duplicating sentences at dataset-
265+
# preparation time. Unlike augment_nopunct, this uses a flat
266+
# default ratio (not an auto-detected one), matching the ratio
267+
# parameter augment_initial_punct itself takes.
268+
drop_initial_punct_arg = args.get('drop_initial_punct_prob', 0.20)
269+
if self.eval or not drop_initial_punct_arg or drop_initial_punct_arg <= 0:
270+
self.drop_initial_punct_eligible = False
271+
self.drop_initial_punct_ratio = 0.0
272+
else:
273+
self.drop_initial_punct_eligible = any(record_starts_with_mark(record) for record in self.data)
274+
self.drop_initial_punct_ratio = drop_initial_punct_arg if self.drop_initial_punct_eligible else 0.0
275+
190276
def init_vocab(self, data):
191277
assert self.eval == False # for eval vocab must exist
192278
cutoff = self.args['word_cutoff'] if self.args.get('word_cutoff') is not None else DEFAULT_WORD_CUTOFF
@@ -250,6 +336,11 @@ def __getitem__(self, key):
250336
# lemma) and non-ROOT-prepended fields (head/deprel/text)
251337
# all simply lose their last entry
252338
record = [field[:-1] for field in record]
339+
if self.drop_initial_punct_ratio > 0 and record_can_drop_initial_mark(record):
340+
if random.random() < self.drop_initial_punct_ratio:
341+
# drop the leading ¿/¡ and renumber every remaining word's
342+
# head position down by one -- see drop_initial_mark_from_record
343+
record = drop_initial_mark_from_record(record)
253344
return record
254345

255346
def __iter__(self):

‎stanza/models/parser.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ def build_argparse():
240240
#
241241
# One simple way to fix this is to train on some fraction of training data with punct.
242242
parser.add_argument('--augment_nopunct', type=float, default=None, help='Fraction of punct-ending sentences to dynamically present without the final punct, applied fresh each epoch rather than by duplicating sentences in the training set. Default of None will aim for roughly 10%%')
243+
parser.add_argument('--drop_initial_punct_prob', type=float, default=0.20, help='Probability to drop a leading inverted question or exclamation mark (¿/¡) from a sentence, for languages such as Spanish and Catalan where it should be optional')
243244

244245
parser.add_argument('--wandb', action='store_true', help='Start a wandb session and write the results of training. Only applies to training. Use --wandb_name instead to specify a name')
245246
parser.add_argument('--wandb_name', default=None, help='Name of a wandb session to start when training. Will default to the dataset short name')

‎stanza/models/pos/data.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from torch.nn.utils.rnn import pad_sequence
1010

1111
from stanza.models.common.bert_embedding import filter_data, needs_length_filter
12-
from stanza.models.common.data import map_to_ids, get_long_tensor, get_float_tensor, sort_all
12+
from stanza.models.common.data import map_to_ids, get_long_tensor, get_float_tensor, sort_all, starts_with_initial_mark
1313
from stanza.models.common.utils import DEFAULT_WORD_CUTOFF, simplify_punct
1414
from stanza.models.common.vocab import PAD_ID, VOCAB_PREFIX, CharVocab
1515
from stanza.models.pos.vocab import WordVocab, XPOSVocab, FeatureVocab, MultiVocab
@@ -54,6 +54,27 @@ def __init__(self, doc, args, pretrain, vocab=None, evaluation=False, sort_durin
5454
data = random.sample(data, keep)
5555
logger.debug("Subsample training set with rate {:g}".format(args['sample_train']))
5656

57+
# dynamic leading-¿/¡ drop: some UD treebanks (Spanish, Catalan) have
58+
# every training sentence with a leading inverted question or
59+
# exclamation mark, which the model never learns to do without.
60+
#
61+
# Eligibility is checked against the raw sentence data (before
62+
# self.vocab['word'] is even queried), not by asking whether ¿/¡
63+
# is IN self.vocab['word']. self.vocab['word'] is built with a
64+
# frequency cutoff (DEFAULT_WORD_CUTOFF, or word_cutoff if set) --
65+
# a word appearing fewer times than the cutoff is left out of the
66+
# vocab and maps to UNK instead. In a small treebank, ¿/¡ could
67+
# easily appear only a handful of times and fall under that
68+
# cutoff, which would make a vocab-containment check wrongly say
69+
# "ineligible" even though the mark is genuinely present in the
70+
# data. Scanning the raw sentences directly avoids that failure
71+
# mode, so the augmentation still triggers correctly even on
72+
# small treebanks. starts_with_initial_mark is shared with the
73+
# dependency parser's equivalent eligibility check.
74+
self.drop_initial_punct_eligible = not self.eval and any(
75+
starts_with_initial_mark([w[0] for w in sent]) for sent in data)
76+
self.drop_initial_punct_ratio = args.get('drop_initial_punct_prob', 0.20) if self.drop_initial_punct_eligible else 0.0
77+
5778
data = self.preprocess(data, self.vocab, self.pretrain_vocab, args)
5879

5980
self.data = data
@@ -208,6 +229,25 @@ def __getitem__(self, key):
208229
char = char[:mask] + char[mask+1:]
209230
raw_text = raw_text[:mask] + raw_text[mask+1:]
210231

232+
# dynamic leading-¿/¡ drop (see drop_initial_punct_eligible in
233+
# __init__). Unlike the trailing-punct mask above, this can't be
234+
# done by masking a position in place: removing the FIRST word
235+
# has to shift every later position back by one, so every field
236+
# is sliced consistently rather than one element being replaced
237+
# with a placeholder while the rest stay put.
238+
if (self.drop_initial_punct_ratio > 0 and starts_with_initial_mark(raw_text)
239+
and random.uniform(0, 1) < self.drop_initial_punct_ratio):
240+
words = words[1:]
241+
if upos is not None:
242+
upos = upos[1:]
243+
if xpos is not None:
244+
xpos = xpos[1:]
245+
if ufeats is not None:
246+
ufeats = ufeats[1:]
247+
pretrained = pretrained[1:]
248+
char = char[1:]
249+
raw_text = raw_text[1:]
250+
211251
# get each character from the input sentnece
212252
# chars = [w for sent in char for w in sent]
213253

‎stanza/models/tagger.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def build_argparse():
117117
utils.add_device_args(parser)
118118

119119
parser.add_argument('--augment_nopunct', type=float, default=None, help='Augment the training data by copying this fraction of punct-ending sentences as non-punct. Default of None will aim for roughly 50%%')
120+
parser.add_argument('--drop_initial_punct_prob', type=float, default=0.20, help='Probability to drop a leading inverted question or exclamation mark (¿/¡) from a sentence, for languages such as Spanish and Catalan where it should be optional')
120121

121122
parser.add_argument('--wandb', action='store_true', help='Start a wandb session and write the results of training. Only applies to training. Use --wandb_name instead to specify a name')
122123
parser.add_argument('--wandb_name', default=None, help='Name of a wandb session to start when training. Will default to the dataset short name')

0 commit comments

Comments
 (0)