bambara-text-normalizer 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ # Copyright 2026 sudoping01.
2
+
3
+ # Licensed under the MIT License; you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at:
5
+
6
+ # https://opensource.org/licenses/MIT
7
+
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ __version__ = "1.0.0"
15
+ __author__ = "sudoping01"
16
+ __license__ = "MIT"
17
+
18
+
19
+ from .dates import (
20
+ DAYS_OF_WEEK,
21
+ MONTHS,
22
+ bambara_to_date,
23
+ bambara_to_day_of_week,
24
+ bambara_to_month,
25
+ date_to_bambara,
26
+ day_of_week_to_bambara,
27
+ denormalize_dates_in_text,
28
+ format_date_bambara,
29
+ is_bambara_day,
30
+ is_bambara_month,
31
+ month_to_bambara,
32
+ normalize_dates_in_text,
33
+ )
34
+ from .evaluation import (
35
+ BambaraEvaluator,
36
+ BambaraTransform,
37
+ EvaluationResult,
38
+ compute_cer,
39
+ compute_der,
40
+ compute_mer,
41
+ compute_wer,
42
+ compute_wil,
43
+ compute_wip,
44
+ create_bambara_transform,
45
+ evaluate,
46
+ evaluate_batch,
47
+ visualize_alignment,
48
+ )
49
+ from .normalizer import (
50
+ BambaraNormalizer,
51
+ BambaraNormalizerConfig,
52
+ create_normalizer,
53
+ )
54
+ from .numbers import (
55
+ bambara_to_number,
56
+ denormalize_numbers_in_text,
57
+ is_number_word,
58
+ normalize_numbers_in_text,
59
+ number_to_bambara,
60
+ number_to_ordinal,
61
+ )
62
+ from .times import (
63
+ LERE,
64
+ MINITI,
65
+ NEGE_KANYE,
66
+ SEGONI,
67
+ bambara_to_duration,
68
+ bambara_to_time,
69
+ denormalize_times_in_text,
70
+ duration_to_bambara,
71
+ format_duration_bambara,
72
+ format_time_bambara,
73
+ is_time_word,
74
+ normalize_times_in_text,
75
+ time_to_bambara,
76
+ )
77
+ from .utils import (
78
+ BAMBARA_ALPHABET,
79
+ BAMBARA_CONSONANTS,
80
+ BAMBARA_SPECIAL_CHARS,
81
+ BAMBARA_VOWELS,
82
+ add_tone,
83
+ analyze_text,
84
+ count_tone_marks,
85
+ get_base_char,
86
+ get_tone,
87
+ get_unicode_info,
88
+ has_tone_marks,
89
+ is_bambara_char,
90
+ is_bambara_special_char,
91
+ normalize_unicode_variants,
92
+ remove_tones,
93
+ validate_bambara_text,
94
+ )
95
+
96
+ __all__ = [
97
+ "BambaraNormalizer",
98
+ "BambaraNormalizerConfig",
99
+ "NormalizationLevel",
100
+ "BambaraEvaluator",
101
+ "EvaluationResult",
102
+ "create_normalizer",
103
+ "compute_wer",
104
+ "compute_cer",
105
+ "compute_der",
106
+ "compute_mer",
107
+ "compute_wil",
108
+ "compute_wip",
109
+ "evaluate",
110
+ "evaluate_batch",
111
+ "visualize_alignment",
112
+ "create_bambara_transform",
113
+ "BambaraTransform",
114
+ "is_bambara_char",
115
+ "is_bambara_special_char",
116
+ "get_base_char",
117
+ "get_tone",
118
+ "add_tone",
119
+ "remove_tones",
120
+ "has_tone_marks",
121
+ "count_tone_marks",
122
+ "validate_bambara_text",
123
+ "analyze_text",
124
+ "normalize_unicode_variants",
125
+ "get_unicode_info",
126
+ "BAMBARA_VOWELS",
127
+ "BAMBARA_CONSONANTS",
128
+ "BAMBARA_SPECIAL_CHARS",
129
+ "BAMBARA_ALPHABET",
130
+ "number_to_bambara",
131
+ "bambara_to_number",
132
+ "normalize_numbers_in_text",
133
+ "denormalize_numbers_in_text",
134
+ "is_number_word",
135
+ "number_to_ordinal",
136
+ "date_to_bambara",
137
+ "bambara_to_date",
138
+ "format_date_bambara",
139
+ "day_of_week_to_bambara",
140
+ "bambara_to_day_of_week",
141
+ "month_to_bambara",
142
+ "bambara_to_month",
143
+ "normalize_dates_in_text",
144
+ "denormalize_dates_in_text",
145
+ "is_bambara_month",
146
+ "is_bambara_day",
147
+ "DAYS_OF_WEEK",
148
+ "MONTHS",
149
+ "time_to_bambara",
150
+ "bambara_to_time",
151
+ "format_time_bambara",
152
+ "duration_to_bambara",
153
+ "bambara_to_duration",
154
+ "format_duration_bambara",
155
+ "normalize_times_in_text",
156
+ "denormalize_times_in_text",
157
+ "is_time_word",
158
+ "NEGE_KANYE",
159
+ "LERE",
160
+ "MINITI",
161
+ "SEGONI",
162
+ ]
163
+
164
+
165
+ def normalize(text: str, preset: str = "standard", **kwargs) -> str:
166
+ """Convenience function for quick normalization.
167
+
168
+ Args:
169
+ text: Text to normalize
170
+ preset: Preset configuration ("standard", "wer", "cer", "minimal")
171
+ **kwargs: Override specific configuration options
172
+
173
+ Returns:
174
+ Normalized text
175
+
176
+ Example:
177
+ >>> from bambara_normalizer import normalize
178
+ >>> normalize("B'a fɔ́!")
179
+ 'bɛ a fɔ'
180
+ >>> normalize("B'a fɔ́!", preset="wer")
181
+ 'bɛ a fɔ'
182
+ """
183
+ normalizer = create_normalizer(preset, **kwargs)
184
+ return normalizer(text)
185
+
186
+
187
+ __all__.append("normalize")
@@ -0,0 +1,298 @@
1
+ # Copyright 2026 sudoping01.
2
+
3
+ # Licensed under the MIT License; you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at:
5
+
6
+ # https://opensource.org/licenses/MIT
7
+
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+
15
+ """
16
+ Command-line interface for Bambara text normalizer.
17
+
18
+ Usage:
19
+ bambara-normalize "B'a fɔ́"
20
+ bambara-normalize --mode expand "B'a fɔ́"
21
+ bambara-normalize --mode contract "bɛ a fɔ"
22
+ bambara-normalize --preset wer --mode contract "text"
23
+ echo "text" | bambara-normalize
24
+ bambara-normalize --file input.txt --output output.txt
25
+ bambara-normalize --evaluate ref.txt hyp.txt
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ from .evaluation import BambaraEvaluator
35
+ from .normalizer import BambaraNormalizer, BambaraNormalizerConfig, create_normalizer
36
+
37
+
38
+ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser(
40
+ prog="bambara-normalize",
41
+ description="Bambara text normalizer for ASR evaluation",
42
+ formatter_class=argparse.RawDescriptionHelpFormatter,
43
+ epilog="""
44
+ Examples:
45
+ Normalize text (expand contractions, default):
46
+ bambara-normalize "B'a fɔ́"
47
+ bambara-normalize --mode expand "k'a ta"
48
+
49
+ Contract expanded forms:
50
+ bambara-normalize --mode contract "bɛ a fɔ"
51
+ bambara-normalize --mode contract "ka a ta"
52
+
53
+ Preserve contractions (don't touch):
54
+ bambara-normalize --mode preserve "B'a fɔ"
55
+
56
+ Use WER preset with contraction:
57
+ bambara-normalize --preset wer --mode contract "ka a ta"
58
+
59
+ Process file:
60
+ bambara-normalize --file input.txt --output normalized.txt
61
+ bambara-normalize --mode contract --file input.txt
62
+
63
+ Evaluate ASR output:
64
+ bambara-normalize --evaluate reference.txt hypothesis.txt
65
+ bambara-normalize --evaluate --mode contract ref.txt hyp.txt
66
+
67
+ Pipe from stdin:
68
+ echo "B'a fɔ́" | bambara-normalize
69
+ echo "bɛ a fɔ" | bambara-normalize --mode contract
70
+ """,
71
+ )
72
+
73
+ parser.add_argument(
74
+ "text",
75
+ nargs="?",
76
+ help="Text to normalize (reads from stdin if not provided)",
77
+ )
78
+
79
+ parser.add_argument(
80
+ "--mode",
81
+ "-m",
82
+ choices=["expand", "contract", "preserve"],
83
+ default="expand",
84
+ help="Contraction mode: expand (default), contract, or preserve",
85
+ )
86
+
87
+ parser.add_argument(
88
+ "--preset",
89
+ "-p",
90
+ choices=["standard", "wer", "cer", "minimal", "preserving_tones"],
91
+ default="standard",
92
+ help="Normalization preset (default: standard)",
93
+ )
94
+
95
+ parser.add_argument(
96
+ "--file",
97
+ "-f",
98
+ type=Path,
99
+ help="Input file to normalize",
100
+ )
101
+
102
+ parser.add_argument(
103
+ "--output",
104
+ "-o",
105
+ type=Path,
106
+ help="Output file (default: stdout)",
107
+ )
108
+
109
+ parser.add_argument(
110
+ "--evaluate",
111
+ "-e",
112
+ nargs=2,
113
+ metavar=("REF", "HYP"),
114
+ type=Path,
115
+ help="Evaluate hypothesis against reference (provide two files)",
116
+ )
117
+
118
+ parser.add_argument(
119
+ "--preserve-tones",
120
+ action="store_true",
121
+ help="Preserve tone marks during normalization",
122
+ )
123
+
124
+ parser.add_argument(
125
+ "--expand-numbers",
126
+ action="store_true",
127
+ help="Expand numbers to Bambara words",
128
+ )
129
+
130
+ parser.add_argument(
131
+ "--debug",
132
+ action="store_true",
133
+ help="Show normalization steps",
134
+ )
135
+
136
+ parser.add_argument(
137
+ "--version",
138
+ "-v",
139
+ action="version",
140
+ version="%(prog)s 2.0.0",
141
+ )
142
+
143
+ return parser.parse_args(args)
144
+
145
+
146
+ def normalize_text(
147
+ text: str,
148
+ preset: str,
149
+ mode: str = "expand",
150
+ preserve_tones: bool = False,
151
+ expand_numbers: bool = False,
152
+ debug: bool = False,
153
+ ) -> str:
154
+ kwargs = {}
155
+ if preserve_tones:
156
+ kwargs["preserve_tones"] = True
157
+ if expand_numbers:
158
+ kwargs["expand_numbers"] = True
159
+
160
+ normalizer = create_normalizer(preset, mode=mode, **kwargs)
161
+
162
+ if debug:
163
+ diff = normalizer.get_normalization_diff(text)
164
+ print("Normalization steps:", file=sys.stderr)
165
+ for step, value in diff.items():
166
+ print(f" {step}: {value}", file=sys.stderr)
167
+ print(file=sys.stderr)
168
+
169
+ return normalizer(text)
170
+
171
+
172
+ def process_file(
173
+ input_path: Path,
174
+ output_path: Path | None,
175
+ normalizer: BambaraNormalizer,
176
+ ) -> None:
177
+ with open(input_path, encoding="utf-8") as f:
178
+ lines = f.readlines()
179
+
180
+ normalized = [normalizer(line.rstrip("\n")) for line in lines]
181
+
182
+ if output_path:
183
+ with open(output_path, "w", encoding="utf-8") as f:
184
+ f.write("\n".join(normalized) + "\n")
185
+ print(f"Normalized output written to {output_path}", file=sys.stderr)
186
+ else:
187
+ for line in normalized:
188
+ print(line)
189
+
190
+
191
+ def run_evaluation(
192
+ ref_path: Path,
193
+ hyp_path: Path,
194
+ preset: str,
195
+ mode: str = "expand",
196
+ ) -> None:
197
+ with open(ref_path, encoding="utf-8") as f:
198
+ references = [line.strip() for line in f if line.strip()]
199
+
200
+ with open(hyp_path, encoding="utf-8") as f:
201
+ hypotheses = [line.strip() for line in f if line.strip()]
202
+
203
+ if len(references) != len(hypotheses):
204
+ print(
205
+ f"Error: Reference ({len(references)} lines) and hypothesis "
206
+ f"({len(hypotheses)} lines) have different lengths",
207
+ file=sys.stderr,
208
+ )
209
+ sys.exit(1)
210
+
211
+ config = BambaraNormalizerConfig.for_wer_evaluation(mode=mode)
212
+ evaluator = BambaraEvaluator(config=config)
213
+ aggregate, individual = evaluator.evaluate_batch(references, hypotheses)
214
+
215
+ print("=" * 60)
216
+ print("Bambara ASR Evaluation Results")
217
+ print("=" * 60)
218
+ print(f"Reference file: {ref_path}")
219
+ print(f"Hypothesis file: {hyp_path}")
220
+ print(f"Normalization preset: {preset}")
221
+ print(f"Contraction mode: {mode}")
222
+ print(f"Total utterances: {len(references)}")
223
+ print("-" * 60)
224
+ print(f"Word Error Rate (WER): {aggregate.wer:.2%}")
225
+ print(f" Substitutions: {aggregate.word_substitutions}")
226
+ print(f" Deletions: {aggregate.word_deletions}")
227
+ print(f" Insertions: {aggregate.word_insertions}")
228
+ print(f" Reference words: {aggregate.total_reference_words}")
229
+ print("-" * 60)
230
+ print(f"Character Error Rate (CER): {aggregate.cer:.2%}")
231
+ print(f" Substitutions: {aggregate.char_substitutions}")
232
+ print(f" Deletions: {aggregate.char_deletions}")
233
+ print(f" Insertions: {aggregate.char_insertions}")
234
+ print(f" Reference chars: {aggregate.total_reference_chars}")
235
+ print("=" * 60)
236
+
237
+
238
+ def main(args: list[str] | None = None) -> int:
239
+ parsed = parse_args(args)
240
+
241
+ try:
242
+ if parsed.evaluate:
243
+ ref_path, hyp_path = parsed.evaluate
244
+ run_evaluation(ref_path, hyp_path, parsed.preset, parsed.mode)
245
+ return 0
246
+
247
+ kwargs = {}
248
+ if parsed.preserve_tones:
249
+ kwargs["preserve_tones"] = True
250
+ if parsed.expand_numbers:
251
+ kwargs["expand_numbers"] = True
252
+
253
+ normalizer = create_normalizer(parsed.preset, mode=parsed.mode, **kwargs)
254
+
255
+ if parsed.file:
256
+ process_file(parsed.file, parsed.output, normalizer)
257
+ return 0
258
+
259
+ if parsed.text:
260
+ text = parsed.text
261
+ elif not sys.stdin.isatty():
262
+ text = sys.stdin.read().strip()
263
+ else:
264
+ print("Error: No input provided", file=sys.stderr)
265
+ print(
266
+ "Usage: bambara-normalize <text> or echo <text> | bambara-normalize",
267
+ file=sys.stderr,
268
+ )
269
+ return 1
270
+
271
+ result = normalize_text(
272
+ text,
273
+ parsed.preset,
274
+ parsed.mode,
275
+ parsed.preserve_tones,
276
+ parsed.expand_numbers,
277
+ parsed.debug,
278
+ )
279
+
280
+ if parsed.output:
281
+ with open(parsed.output, "w", encoding="utf-8") as f:
282
+ f.write(result + "\n")
283
+ print(f"Output written to {parsed.output}", file=sys.stderr)
284
+ else:
285
+ print(result)
286
+
287
+ return 0
288
+
289
+ except FileNotFoundError as e:
290
+ print(f"Error: File not found: {e}", file=sys.stderr)
291
+ return 1
292
+ except Exception as e:
293
+ print(f"Error: {e}", file=sys.stderr)
294
+ return 1
295
+
296
+
297
+ if __name__ == "__main__":
298
+ sys.exit(main())
@@ -0,0 +1,197 @@
1
+ # Copyright 2026 sudoping01.
2
+
3
+ # Licensed under the MIT License; you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at:
5
+
6
+ # https://opensource.org/licenses/MIT
7
+
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+
19
+
20
+ class ContractionMode(Enum):
21
+ EXPAND = "expand"
22
+ CONTRACT = "contract"
23
+ PRESERVE = "preserve"
24
+
25
+
26
+ class NormalizationLevel(Enum):
27
+ MINIMAL = "minimal"
28
+ STANDARD = "standard"
29
+ AGGRESSIVE = "aggressive"
30
+
31
+
32
+ @dataclass
33
+ class BambaraNormalizerConfig:
34
+ """
35
+ Configuration for Bambara text normalization.
36
+
37
+ Attributes:
38
+ contraction_mode: How to handle contractions ("expand", "contract", "preserve")
39
+ preserve_tones: Keep tone diacritics (à, á, etc.)
40
+ normalize_legacy_orthography: Convert old spellings (è→ɛ, ny→ɲ)
41
+ lowercase: Convert to lowercase
42
+ remove_punctuation: Remove punctuation marks
43
+ normalize_whitespace: Collapse multiple spaces
44
+ normalize_apostrophes: Standardize apostrophe variants
45
+ normalize_special_chars: Handle variant forms of ɛ, ɔ, ɲ, ŋ
46
+ expand_numbers: Convert digits to Bambara words
47
+ expand_dates: Convert date patterns (DD-MM-YYYY) to Bambara
48
+ remove_diacritics_except_tones: Remove non-tonal diacritics
49
+ handle_french_loanwords: Apply French word normalization
50
+ strip_repetitions: Normalize repeated characters
51
+ normalize_compounds: Standardize compound word spacing
52
+ expand_times: Convert time patterns to Bambara
53
+ """
54
+
55
+ contraction_mode: str = "expand"
56
+ preserve_tones: bool = True
57
+ normalize_legacy_orthography: bool = True
58
+ lowercase: bool = True
59
+ remove_punctuation: bool = True
60
+ normalize_whitespace: bool = True
61
+ normalize_apostrophes: bool = True
62
+ normalize_special_chars: bool = True
63
+ expand_numbers: bool = False
64
+ expand_dates: bool = False
65
+ expand_times: bool = False
66
+ remove_diacritics_except_tones: bool = False
67
+ handle_french_loanwords: bool = True
68
+ strip_repetitions: bool = False
69
+ normalize_compounds: bool = False
70
+
71
+ @property
72
+ def expand_contractions(self) -> bool:
73
+ """Backward compatibility: True if mode is 'expand'."""
74
+ return self.contraction_mode == "expand"
75
+
76
+ @expand_contractions.setter
77
+ def expand_contractions(self, value: bool) -> None:
78
+ """Backward compatibility setter."""
79
+ if value:
80
+ self.contraction_mode = "expand"
81
+ else:
82
+ self.contraction_mode = "preserve"
83
+
84
+ @classmethod
85
+ def for_wer_evaluation(cls, mode: str = "expand") -> BambaraNormalizerConfig:
86
+ return cls(
87
+ contraction_mode=mode,
88
+ preserve_tones=False,
89
+ normalize_legacy_orthography=True,
90
+ lowercase=True,
91
+ remove_punctuation=True,
92
+ normalize_whitespace=True,
93
+ normalize_apostrophes=True,
94
+ normalize_special_chars=True,
95
+ expand_numbers=True,
96
+ expand_dates=True,
97
+ expand_times=True,
98
+ remove_diacritics_except_tones=True,
99
+ handle_french_loanwords=True,
100
+ strip_repetitions=True,
101
+ normalize_compounds=True,
102
+ )
103
+
104
+ @classmethod
105
+ def for_cer_evaluation(cls, mode: str = "expand") -> BambaraNormalizerConfig:
106
+ return cls(
107
+ contraction_mode=mode,
108
+ preserve_tones=False,
109
+ normalize_legacy_orthography=True,
110
+ lowercase=True,
111
+ remove_punctuation=True,
112
+ normalize_whitespace=True,
113
+ normalize_apostrophes=True,
114
+ normalize_special_chars=True,
115
+ expand_numbers=True,
116
+ expand_dates=True,
117
+ expand_times=True,
118
+ remove_diacritics_except_tones=True,
119
+ handle_french_loanwords=True,
120
+ strip_repetitions=False,
121
+ normalize_compounds=True,
122
+ )
123
+
124
+ @classmethod
125
+ def preserving_tones(cls, mode: str = "expand") -> BambaraNormalizerConfig:
126
+ return cls(
127
+ contraction_mode=mode,
128
+ preserve_tones=True,
129
+ normalize_legacy_orthography=True,
130
+ lowercase=True,
131
+ remove_punctuation=True,
132
+ normalize_whitespace=True,
133
+ normalize_apostrophes=True,
134
+ normalize_special_chars=True,
135
+ expand_numbers=False,
136
+ expand_dates=False,
137
+ expand_times=False,
138
+ remove_diacritics_except_tones=False,
139
+ handle_french_loanwords=False,
140
+ strip_repetitions=False,
141
+ normalize_compounds=False,
142
+ )
143
+
144
+ @classmethod
145
+ def minimal(cls) -> BambaraNormalizerConfig:
146
+ return cls(
147
+ contraction_mode="preserve",
148
+ preserve_tones=True,
149
+ normalize_legacy_orthography=False,
150
+ lowercase=True,
151
+ remove_punctuation=False,
152
+ normalize_whitespace=True,
153
+ normalize_apostrophes=True,
154
+ normalize_special_chars=False,
155
+ expand_numbers=False,
156
+ expand_dates=False,
157
+ expand_times=False,
158
+ remove_diacritics_except_tones=False,
159
+ handle_french_loanwords=False,
160
+ strip_repetitions=False,
161
+ normalize_compounds=False,
162
+ )
163
+
164
+
165
+ @dataclass
166
+ class EvaluationResult:
167
+ wer: float
168
+ cer: float
169
+ mer: float = 0.0
170
+ wil: float = 0.0
171
+ wip: float = 0.0
172
+ der: float | None = None
173
+
174
+ word_substitutions: int = 0
175
+ word_deletions: int = 0
176
+ word_insertions: int = 0
177
+ word_hits: int = 0
178
+ total_reference_words: int = 0
179
+
180
+ char_substitutions: int = 0
181
+ char_deletions: int = 0
182
+ char_insertions: int = 0
183
+ char_hits: int = 0
184
+ total_reference_chars: int = 0
185
+
186
+ reference_normalized: str = ""
187
+ hypothesis_normalized: str = ""
188
+
189
+ def __str__(self) -> str:
190
+ return (
191
+ f"WER: {self.wer:.2%} "
192
+ f"(S={self.word_substitutions}, D={self.word_deletions}, I={self.word_insertions}, H={self.word_hits}, N={self.total_reference_words})\n"
193
+ f"CER: {self.cer:.2%} "
194
+ f"(S={self.char_substitutions}, D={self.char_deletions}, I={self.char_insertions}, H={self.char_hits}, N={self.total_reference_chars})\n"
195
+ f"MER: {self.mer:.2%} | WIL: {self.wil:.2%} | WIP: {self.wip:.2%}"
196
+ + (f"\nDER: {self.der:.2%}" if self.der is not None else "")
197
+ )