[feat] add regex_replace_feature and align tokenize/text_normalizer with fg - #672
Conversation
d70f1cf to
d35032b
Compare
d35032b to
a4c8fe0
Compare
| feature_configs { | ||
| sequence_feature { | ||
| sequence_name: "click_50_seq" | ||
| sequence_length: 50 |
There was a problem hiding this comment.
Minor: SequenceFeature.sequence_delim is proto2 required (feature.proto:1071), but this example omits it. The current loader (text_format.Merge) tolerates it and the getter falls back to ";", but strict paths (text_format.Parse / IsInitialized, C++-side consumers) reject the message. Every other sequence_feature example in the docs (faq.md Q5, feature.md) sets it explicitly, as does this PR's own grouped-sequence test. Suggest adding sequence_delim: ";".
| | 中华\|人民\|共和国 | ["\\\|"] | " " | 中华 人民 共和国 | | ||
| | a\|b#c(d) | ["\\\|", "#", "\\(.\*\\)"] | "" | abc | |
There was a problem hiding this comment.
Minor: the regex_pattern cells are one escaping level short. Raw ["\\\|"] renders in the table as ["\|"] (markdown eats \\→\ and \|→|), and \| / \( are invalid escapes in pbtxt strings — a user copying the rendered table into a config gets a ParseError. The fenced example above correctly shows ["\\|", "#"]. Either add one more backslash level in the raw markdown (e.g. ["\\\\\|"] → renders ["\\|"]) or label this column as the effective regex rather than the config value. The input/output columns themselves are correct.
| np.testing.assert_allclose(parsed_feat.values, np.array(expected_values)) | ||
| np.testing.assert_allclose(parsed_feat.lengths, np.array(expected_lengths)) | ||
|
|
||
| @parameterized.expand( |
There was a problem hiding this comment.
Coverage gap in the bucketize/modifier branches fg_json() copies from IdFeature:
hash_bucket_size— the primary config in the feature.md example — never runs through fg: the fg-encoded test above is FG_NONE (which skipsfg_json()entirely), and the missing-pattern test raises before the bucketize chain. Deleting theelif HasField("hash_bucket_size")branch would keep CI green.- The
value_dim: 0+separatormulti-value mode that the new docs promise is untested; all current cases are single-value, so both theseparatorbranch and thefg_cfg["value_dim"]emission are unobservable.
One FG_NORMAL case with hash_bucket_size, or with value_dim=0 + separator=" " and a replacement producing a multi-value string (asserting lengths > 1), would pin both — alternatively fg_json dict asserts like the sequence tests do. Same applies to a lesser extent to the zch/vocab_dict/vocab_file branches and the SINGLE_INPUT_FEATURE_CLASSES line in feature.py, which nothing exercises with a user:-side sequence expression.
Code Review SummaryReviewed across five areas (code quality, performance, test coverage, documentation accuracy, security). Overall this is a solid, well-verified PR — no bugs found in the implementation, and the fg-behavior claims in the docs check out. Verified correct:
Posted 3 inline comments (all minor):
Optional nits (no action required):
🤖 Generated with Claude Code |
5634c2d to
b6256d0
Compare
…ith fg Wrap the pyfg regex_replace_feature op as a feature type. fg has no sequence_regex_replace_feature, the sequence version is activated by is_sequence, so the class builds its fg json in fg_json() instead of _fg_json(). value_dim defaults to 1, otherwise the output column is array<string> and can not be consumed by tokenize_feature. It lets a text be truncated and suffixed with an EOS literal before tokenization, which tokenize_feature can not do itself: the tokenizer never adds special tokens, and a truncation block in tokenizer.json runs after tokenization and would cut the EOS off again. Also fix what the tokenize path got wrong about fg. norm_options were summed instead of or-ed, so a repeated option set an unrelated bit, and the documented default normalization was lower-to-upper while fg applies upper-to-lower. regex_pattern is required by fg but proto can not mark a repeated field required, and an empty pattern list compiles to `(?:)`, which inserts the replacement between every character. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VkWBjeAQP8rcsutwL6rkfV
b6256d0 to
7c184ee
Compare
Add
regex_replace_feature, a wrapper around the pyfg op of the same name, which replaces text matched by one or more RE2 patterns. It works as a scalar feature, as a flatsequence_regex_replace_feature, and as a sub-feature of a groupedsequence_feature.Two things are worth calling out in the implementation:
sequence_regex_replace_feature; the sequence version is activated byis_sequence(the same 「特殊情况1」 family ascustom_feature). So the class builds its fg json infg_json()rather than_fg_json(), otherwiseBaseFeature.fg_jsonwould rename the type to something fg does not register.value_dimdefaults to 1 instead of fg's 0. With 0 the output column type isarray<string>, whichtokenize_featurerejects withhas invalid input type.The motivating use case is truncating a text and appending an EOS token before tokenization, which
tokenize_featurecan not do on its own: the tokenizer is called without special tokens, and atruncationblock intokenizer.jsonruns after tokenization and cuts the freshly appended EOS off. Oneregex_replace_featurewith(?s)^(.{0,N}).*$/\1<|im_end|>does both in a single op, upstream of the tokenizer. FAQ Q19 describes the chain for a single text and for a sequence of texts, and Q20 covers whypaddingintokenizer.jsonis usually the wrong tool.num_bucketsis kept for parity with the other string features, with a documented caveat: it only works while every replaced string parses as an integer in[0, num_buckets), otherwise fg raises.Tokenize / text_normalizer fixes
While writing the docs above, the tokenize path turned out to describe fg incorrectly in several places. Everything below was checked against the fg C++ and re-measured with pyfg 1.0.6:
norm_optionswere summed, not or-ed (parameter += ...): a repeated option silently set an unrelated bit —[TEXT_FILTER, TEXT_FILTER]produced 64, which is the unimplemented__NORMALIZED_SYNONYMS__, so filtering was simply off. Now or-ed.TEXT_LOWER2UPPER, ..., butnmConstant.hdefines the default asUPPER2LOWER|SBC2DBC|BIG52GBK|FILTER(=60) — upper→lower. The example config carried the same wrong option.TEXT_REMOVE_SPACEalone is a no-op switch: it is not a bit, so configuring only it emitsparameter: 0, and fg treats 0 as "use the default set". "Only remove spaces" is not expressible; documented.TEXT_FILTERreplaces the character with a space, it does not delete it (spaces are merged and trimmed afterwards).stop_char_filemust be GBK-encoded with one character per line, and it replaces the built-in table — a UTF-8 file raises no error and matches nothing.tokenizer_type: bpereally means "a huggingface tokenizers json", whose algorithm (BPE, WordPiece, …) is decided by the json; the doc implied WordPiece was unsupported.tokenize_feature.default_valueis text that gets tokenized, and sequence-mode features silently rewrite an empty default to"0", i.e. the token for the literal string0.regex_patternis required by fg, but proto can not mark a repeated field required. An empty list compiles to(?:), which matches the empty string everywhere and inserts the replacement between every character, with no error anywhere. It now raises.Test Plan
tzrec/features/regex_replace_feature_test.py(16 cases): fg-encoded parsing, FG_NORMAL forreplace_all/replace_first/ multi-pattern /icase/num_buckets/default_value(which fg emits verbatim, without the regex), the missing-regex_patternerror,hash_bucket_sizethrough fg, a multi-value array input withvalue_dim: 0, the flat sequence form asserting the fg json keepsregex_replace_feature+is_sequence, the grouped sequence sub-feature, and two end-to-end truncate+EOS+tokenize tests that run the whole DAG throughcreate_fg_json+pyfg.FgArrowHandlerwithdata/test/tokenizer.jsonand assert the EOS id lands at the end of each text.tokenize_feature_test.pyasserting[TEXT_UPPER2LOWER, TEXT_UPPER2LOWER, TEXT_FILTER]yields4 | 32rather than the summed 40.Qwen/Qwen3.5-0.8Btokenizer.json: every text ends with exactly one<|im_end|>, both scalar and per sequence element, and the text is cut at the configured character count."ABC EFG!"→"abc efg", theparameter: 0fallback →"abcefg", and the empty-pattern corruption"abc"→"XaXbXcX".tzrec/features/feature_test.py,tokenize_feature_test.py,id_feature_test.py,custom_feature_test.py,bool_mask_feature_test.pyandtzrec/datasets/data_parser_test.pypass (the only shared change is addingRegexReplaceFeaturetoSINGLE_INPUT_FEATURE_CLASSES).feature_configs { ... }example infaq.mdwas parsed withtext_formatand serialized, to catch missing proto2requiredfields (the Q19 grouped example was missingsequence_delim), and the doc table'sregex_patterncells were rendered with markdown-it and fed back throughtext_formatto confirm they are copy-pasteable.pre-commit run --files <changed>andpyrefly checkclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01VkWBjeAQP8rcsutwL6rkfV