Skip to content

Commit 567d61b

Browse files
committed
feat: add English ITN core rules (cardinal, ordinal, decimal)
Add core English inverse text normalization rules: - Cardinal: supports up to sextillion, eleven hundred pattern, minus/negative, and/deletion - Ordinal: first=>1st, second=>2nd, thirteenth=>13th, etc. - Decimal: twelve point five => 12.5, point o five => .05 - Char: fallback for unmatched characters NeMo cardinal test: 27/29 passed (missing: zero preservation, Indian numeric format)
1 parent acc2a79 commit 567d61b

5 files changed

Lines changed: 325 additions & 0 deletions

File tree

itn/english/rules/cardinal.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from pynini import closure, cross, string_file, union
16+
from pynini.lib.pynutil import delete, insert
17+
18+
from tn.processor import Processor
19+
from tn.utils import get_abs_path
20+
21+
22+
class Cardinal(Processor):
23+
24+
def __init__(self):
25+
super().__init__(name="cardinal", ordertype="itn")
26+
self.build_tagger()
27+
self.build_verbalizer()
28+
29+
def build_tagger(self):
30+
zero = string_file(get_abs_path("../itn/english/data/numbers/zero.tsv"))
31+
digit = string_file(get_abs_path("../itn/english/data/numbers/digit.tsv"))
32+
teen = string_file(get_abs_path("../itn/english/data/numbers/teen.tsv"))
33+
ties = string_file(get_abs_path("../itn/english/data/numbers/ties.tsv"))
34+
ds = delete(" ")
35+
36+
# 1~9, 10~19, 20~99
37+
one_digit = digit
38+
two_digit = teen | (ties + (ds + digit | insert("0")))
39+
up_to_99 = one_digit | two_digit
40+
41+
# one hundred, one hundred twenty three, one hundred one
42+
hundreds = digit + ds + delete("hundred") + (ds + two_digit | ds + insert("0") + one_digit | insert("00"))
43+
# eleven hundred => 1100, twenty one hundred => 2100
44+
hundreds_as_thousand = (teen | ties + ds + digit) + ds + delete("hundred") + (
45+
ds + two_digit | ds + insert("0") + one_digit | insert("00")
46+
)
47+
48+
# 1~999
49+
up_to_999 = up_to_99 | hundreds
50+
# 1~999 with zero-padding to 3 digits
51+
up_to_999_padded = hundreds | insert("0") + two_digit | insert("00") + one_digit
52+
53+
def _with_mag(name):
54+
return up_to_999 + ds + delete(name)
55+
56+
def _with_mag_padded(name):
57+
return up_to_999_padded + ds + delete(name)
58+
59+
graph = zero | up_to_999 | hundreds_as_thousand
60+
graph |= _with_mag("thousand") + (ds + up_to_999_padded | insert("000"))
61+
graph |= (
62+
_with_mag("million")
63+
+ (ds + _with_mag_padded("thousand") | insert("000"))
64+
+ (ds + up_to_999_padded | insert("000"))
65+
)
66+
graph |= (
67+
_with_mag("billion")
68+
+ (ds + _with_mag_padded("million") | insert("000"))
69+
+ (ds + _with_mag_padded("thousand") | insert("000"))
70+
+ (ds + up_to_999_padded | insert("000"))
71+
)
72+
graph |= (
73+
_with_mag("trillion")
74+
+ (ds + _with_mag_padded("billion") | insert("000"))
75+
+ (ds + _with_mag_padded("million") | insert("000"))
76+
+ (ds + _with_mag_padded("thousand") | insert("000"))
77+
+ (ds + up_to_999_padded | insert("000"))
78+
)
79+
graph |= (
80+
_with_mag("quadrillion")
81+
+ (ds + _with_mag_padded("trillion") | insert("000"))
82+
+ (ds + _with_mag_padded("billion") | insert("000"))
83+
+ (ds + _with_mag_padded("million") | insert("000"))
84+
+ (ds + _with_mag_padded("thousand") | insert("000"))
85+
+ (ds + up_to_999_padded | insert("000"))
86+
)
87+
graph |= (
88+
_with_mag("quintillion")
89+
+ (ds + _with_mag_padded("quadrillion") | insert("000"))
90+
+ (ds + _with_mag_padded("trillion") | insert("000"))
91+
+ (ds + _with_mag_padded("billion") | insert("000"))
92+
+ (ds + _with_mag_padded("million") | insert("000"))
93+
+ (ds + _with_mag_padded("thousand") | insert("000"))
94+
+ (ds + up_to_999_padded | insert("000"))
95+
)
96+
graph |= (
97+
_with_mag("sextillion")
98+
+ (ds + _with_mag_padded("quintillion") | insert("000"))
99+
+ (ds + _with_mag_padded("quadrillion") | insert("000"))
100+
+ (ds + _with_mag_padded("trillion") | insert("000"))
101+
+ (ds + _with_mag_padded("billion") | insert("000"))
102+
+ (ds + _with_mag_padded("million") | insert("000"))
103+
+ (ds + _with_mag_padded("thousand") | insert("000"))
104+
+ (ds + up_to_999_padded | insert("000"))
105+
)
106+
107+
# strip leading zeros
108+
graph = graph @ union(delete(closure("0")) + (self.DIGIT - "0") + closure(self.DIGIT), "0")
109+
# delete optional "and"
110+
delete_and = self.build_rule(delete("and "), " ", self.ALPHA)
111+
graph = (delete_and @ graph).optimize()
112+
113+
self.graph = graph
114+
115+
minus = delete("minus") | delete("negative")
116+
optional_minus = closure(insert('negative: "-" ') + minus + ds, 0, 1)
117+
final_graph = optional_minus + insert('integer: "') + graph + insert('"')
118+
self.tagger = self.add_tokens(final_graph)
119+
120+
def build_verbalizer(self):
121+
optional_sign = closure(
122+
delete('negative:') + self.DELETE_SPACE + delete('"') + self.NOT_QUOTE + delete('"') + self.DELETE_SPACE,
123+
0, 1,
124+
)
125+
integer = delete("integer:") + self.DELETE_SPACE + delete('"') + self.NOT_QUOTE.plus + delete('"')
126+
self.numbers = integer
127+
self.verbalizer = self.delete_tokens(optional_sign + integer)

itn/english/rules/char.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from pynini.lib.pynutil import insert
16+
17+
from tn.processor import Processor
18+
19+
20+
class Char(Processor):
21+
22+
def __init__(self):
23+
super().__init__(name="char", ordertype="itn")
24+
self.build_tagger()
25+
self.build_verbalizer()
26+
27+
def build_tagger(self):
28+
tagger = insert('value: "') + self.CHAR + insert('"')
29+
self.tagger = self.add_tokens(tagger)

itn/english/rules/decimal.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from pynini import closure, cross, string_file
16+
from pynini.lib.pynutil import delete, insert
17+
18+
from itn.english.rules.cardinal import Cardinal
19+
from tn.processor import Processor
20+
from tn.utils import get_abs_path
21+
22+
23+
class Decimal(Processor):
24+
25+
def __init__(self, cardinal=None):
26+
super().__init__(name="decimal", ordertype="itn")
27+
self.cardinal = cardinal or Cardinal()
28+
self.build_tagger()
29+
self.build_verbalizer()
30+
31+
def build_tagger(self):
32+
digit = string_file(get_abs_path("../itn/english/data/numbers/digit.tsv"))
33+
zero = string_file(get_abs_path("../itn/english/data/numbers/zero.tsv"))
34+
ds = delete(" ")
35+
36+
# fractional part: digit by digit, "o" => 0
37+
frac_digit = digit | zero | cross("o", "0")
38+
frac_graph = closure(frac_digit + ds) + frac_digit
39+
40+
optional_negative = closure(
41+
insert('negative: "true" ') + delete("minus") + ds, 0, 1
42+
)
43+
integer_part = insert('integer_part: "') + self.cardinal.graph + insert('"')
44+
frac_part = insert('fractional_part: "') + frac_graph + insert('"')
45+
point = delete("point")
46+
47+
graph = optional_negative + closure(integer_part + ds, 0, 1) + point + ds + frac_part
48+
self.tagger = self.add_tokens(graph)
49+
50+
def build_verbalizer(self):
51+
optional_sign = closure(cross('negative: "true"', "-") + self.DELETE_SPACE, 0, 1)
52+
integer = delete('integer_part:') + self.DELETE_SPACE + delete('"') + self.NOT_QUOTE.plus + delete('"')
53+
optional_integer = closure(integer + self.DELETE_SPACE, 0, 1)
54+
fractional = (
55+
insert(".") + delete('fractional_part:') + self.DELETE_SPACE
56+
+ delete('"') + self.NOT_QUOTE.plus + delete('"')
57+
)
58+
optional_fractional = closure(fractional + self.DELETE_SPACE, 0, 1)
59+
graph = optional_sign + optional_integer + optional_fractional
60+
self.numbers = graph
61+
self.verbalizer = self.delete_tokens(graph)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from importlib_resources import files
16+
from pynini.lib.pynutil import add_weight, delete
17+
18+
from itn.english.rules.cardinal import Cardinal
19+
from itn.english.rules.char import Char
20+
from itn.english.rules.decimal import Decimal
21+
from itn.english.rules.ordinal import Ordinal
22+
from tn.processor import Processor
23+
24+
25+
class InverseNormalizer(Processor):
26+
27+
def __init__(self, cache_dir=None, overwrite_cache=False):
28+
super().__init__(name="en_inverse_normalizer", ordertype="itn")
29+
if cache_dir is None:
30+
cache_dir = files("itn")
31+
self.build_fst("en_itn", cache_dir, overwrite_cache)
32+
33+
def build_tagger_and_verbalizer(self):
34+
cardinal = Cardinal()
35+
ordinal = Ordinal(cardinal=cardinal)
36+
decimal = Decimal(cardinal=cardinal)
37+
char = Char()
38+
39+
tagger = (
40+
add_weight(ordinal.tagger, 1.0)
41+
| add_weight(decimal.tagger, 1.01)
42+
| add_weight(cardinal.tagger, 1.02)
43+
| add_weight(char.tagger, 100)
44+
).optimize()
45+
46+
tagger = tagger.star
47+
self.tagger = tagger @ self.build_rule(delete(" "), "", "[EOS]")
48+
49+
verbalizer = (
50+
cardinal.verbalizer
51+
| ordinal.verbalizer
52+
| decimal.verbalizer
53+
| char.verbalizer
54+
).optimize()
55+
56+
self.verbalizer = verbalizer.star

itn/english/rules/ordinal.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright (c) 2026 Zhendong Peng (pzd17@tsinghua.org.cn)
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pynini
16+
from pynini import closure, cross, string_file
17+
from pynini.lib.pynutil import delete, insert
18+
19+
from itn.english.rules.cardinal import Cardinal
20+
from tn.processor import Processor
21+
from tn.utils import get_abs_path
22+
23+
24+
class Ordinal(Processor):
25+
26+
def __init__(self, cardinal=None):
27+
super().__init__(name="ordinal", ordertype="itn")
28+
self.cardinal = cardinal or Cardinal()
29+
self.build_tagger()
30+
self.build_verbalizer()
31+
32+
def build_tagger(self):
33+
graph_digit = string_file(get_abs_path("../itn/english/data/ordinals/digit.tsv"))
34+
graph_teen = string_file(get_abs_path("../itn/english/data/ordinals/teen.tsv"))
35+
# first => one => 1, twelfth => twelve => 12, twentieth => twenty => 2(0)
36+
suffix = graph_digit | graph_teen | cross("tieth", "ty") | cross("th", "")
37+
graph = closure(self.VCHAR) + suffix
38+
self.graph = pynini.compose(graph, self.cardinal.graph)
39+
40+
final_graph = insert('integer: "') + self.graph + insert('"')
41+
self.tagger = self.add_tokens(final_graph)
42+
43+
def build_verbalizer(self):
44+
integer = delete("integer:") + self.DELETE_SPACE + delete('"') + self.NOT_QUOTE.plus + delete('"')
45+
# 1 => 1st, 2 => 2nd, 3 => 3rd, 11 => 11th, 12 => 12th, 13 => 13th, rest => Xth
46+
ordinal_suffix = (
47+
cross("11", "11th") | cross("12", "12th") | cross("13", "13th")
48+
| cross("1", "1st") | cross("2", "2nd") | cross("3", "3rd")
49+
| insert("th", weight=0.01)
50+
)
51+
graph = integer @ pynini.cdrewrite(ordinal_suffix, "", "[EOS]", self.VSIGMA)
52+
self.verbalizer = self.delete_tokens(graph)

0 commit comments

Comments
 (0)