Skip to content

Commit e05290e

Browse files
committed
Initial version of dynamically removing initial punct from sentences - lets us remove that from the dataset processing and lets the models train on all sentences with that executed dynamically.
1 parent 769b349 commit e05290e

13 files changed

Lines changed: 918 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)