Skip to content

Commit 58435d6

Browse files
authored
Merge pull request #7868 from vkarampudi/feature/filter-component
[Experimental] Add FilterComponent for row-level example filtering
2 parents 9bf9468 + 9bef872 commit 58435d6

5 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright 2026 Google LLC. All Rights Reserved.
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+
"""Filter component experimental module."""
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Copyright 2026 Google LLC. All Rights Reserved.
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+
"""TFX experimental Filter component definition."""
15+
16+
from typing import Optional
17+
18+
from tfx import types
19+
from tfx.components.experimental.filter import executor
20+
from tfx.dsl.components.base import base_component
21+
from tfx.dsl.components.base import executor_spec
22+
from tfx.types import standard_artifacts
23+
from tfx.types.component_spec import ChannelParameter
24+
from tfx.types.component_spec import ComponentSpec
25+
from tfx.types.component_spec import ExecutionParameter
26+
27+
28+
class FilterSpec(ComponentSpec):
29+
"""Filter component spec."""
30+
31+
PARAMETERS = {
32+
'filter_fn_path': ExecutionParameter(type=str),
33+
}
34+
INPUTS = {
35+
'examples': ChannelParameter(type=standard_artifacts.Examples),
36+
}
37+
OUTPUTS = {
38+
'filtered_examples': ChannelParameter(type=standard_artifacts.Examples),
39+
}
40+
41+
42+
class FilterComponent(base_component.BaseComponent):
43+
"""A TFX component to filter examples based on a user-defined function.
44+
45+
The FilterComponent reads examples from each split of the input `examples`
46+
artifact, applies a user-defined filter function using an Apache Beam
47+
pipeline, and writes the filtered examples to the `filtered_examples` output
48+
artifact, preserving the split structure.
49+
50+
Example usage:
51+
```python
52+
# Filter out examples where age <= 18
53+
filter_component = FilterComponent(
54+
examples=example_gen.outputs['examples'],
55+
filter_fn_path='my_filters.custom_filter_fn'
56+
)
57+
```
58+
"""
59+
60+
SPEC_CLASS = FilterSpec
61+
EXECUTOR_SPEC = executor_spec.ExecutorClassSpec(executor.Executor)
62+
63+
def __init__(self,
64+
examples: types.BaseChannel,
65+
filter_fn_path: str,
66+
filtered_examples: Optional[types.Channel] = None):
67+
"""Construct a FilterComponent.
68+
69+
Args:
70+
examples: A [BaseChannel] of type [standard_artifacts.Examples].
71+
filter_fn_path: The Python import path to the filter function.
72+
e.g., 'my_module.my_filter_fn'. The function must have the signature:
73+
`def my_filter_fn(serialized_example: bytes) -> bool`
74+
filtered_examples: Optional output channel of type [standard_artifacts.Examples].
75+
"""
76+
if filtered_examples is None:
77+
filtered_examples = types.Channel(type=standard_artifacts.Examples)
78+
79+
spec = FilterSpec(
80+
examples=examples,
81+
filter_fn_path=filter_fn_path,
82+
filtered_examples=filtered_examples)
83+
super().__init__(spec=spec)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2026 Google LLC. All Rights Reserved.
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+
"""Tests for tfx.components.experimental.filter.component."""
15+
16+
import tensorflow as tf
17+
from tfx.components.experimental.filter import component
18+
from tfx.types import standard_artifacts
19+
from tfx.types import channel
20+
21+
22+
class ComponentTest(tf.test.TestCase):
23+
24+
def testConstruct(self):
25+
examples = channel.Channel(type=standard_artifacts.Examples)
26+
filter_fn_path = 'my_module.my_filter_fn'
27+
28+
filter_component = component.FilterComponent(
29+
examples=examples,
30+
filter_fn_path=filter_fn_path
31+
)
32+
33+
# Verify input channel
34+
self.assertEqual(
35+
filter_component.inputs['examples'].type,
36+
standard_artifacts.Examples
37+
)
38+
39+
# Verify output channel
40+
self.assertEqual(
41+
filter_component.outputs['filtered_examples'].type,
42+
standard_artifacts.Examples
43+
)
44+
45+
# Verify parameter
46+
self.assertEqual(
47+
filter_component.exec_properties['filter_fn_path'],
48+
filter_fn_path
49+
)
50+
51+
52+
if __name__ == '__main__':
53+
tf.test.main()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2026 Google LLC. All Rights Reserved.
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+
"""TFX experimental Filter component executor."""
15+
16+
import os
17+
from typing import Any, Dict, List
18+
19+
from absl import logging
20+
import apache_beam as beam
21+
import tensorflow as tf
22+
from tfx import types
23+
from tfx.dsl.components.base import base_beam_executor
24+
from tfx.types import artifact_utils
25+
from tfx.utils import import_utils
26+
27+
28+
class Executor(base_beam_executor.BaseBeamExecutor):
29+
"""TFX experimental Filter component executor."""
30+
31+
def Do(self, input_dict: Dict[str, List[types.Artifact]],
32+
output_dict: Dict[str, List[types.Artifact]],
33+
exec_properties: Dict[str, Any]) -> None:
34+
"""Runs the filter Apache Beam pipeline.
35+
36+
Args:
37+
input_dict: Input dict from input key to a list of Artifacts.
38+
- examples: A list of type `standard_artifacts.Examples` containing
39+
the splits to be filtered.
40+
output_dict: Output dict from output key to a list of Artifacts.
41+
- filtered_examples: A list of type `standard_artifacts.Examples`
42+
where the filtered splits will be written.
43+
exec_properties: A dict of execution properties.
44+
- filter_fn_path: The Python import path to the filter function.
45+
"""
46+
self._log_startup(input_dict, output_dict, exec_properties)
47+
48+
examples = artifact_utils.get_single_instance(input_dict['examples'])
49+
filtered_examples = artifact_utils.get_single_instance(
50+
output_dict['filtered_examples'])
51+
52+
# Setup output splits.
53+
split_names = artifact_utils.decode_split_names(examples.split_names)
54+
filtered_examples.split_names = artifact_utils.encode_split_names(
55+
split_names)
56+
filtered_examples.span = examples.span
57+
filtered_examples.version = examples.version
58+
59+
# Import the user-defined filter function.
60+
filter_fn_path = exec_properties['filter_fn_path']
61+
logging.info('Importing user filter function from: %s', filter_fn_path)
62+
filter_fn = import_utils.import_class_by_path(filter_fn_path)
63+
64+
with self._make_beam_pipeline() as pipeline:
65+
for split in split_names:
66+
input_split_uri = artifact_utils.get_split_uri([examples], split)
67+
output_split_uri = artifact_utils.get_split_uri([filtered_examples],
68+
split)
69+
70+
# Ensure output split directory exists.
71+
tf.io.gfile.makedirs(output_split_uri)
72+
73+
input_pattern = os.path.join(input_split_uri, '*')
74+
output_prefix = os.path.join(output_split_uri, 'data_tfrecord')
75+
76+
logging.info('Filtering split %s. Reading from %s, writing to prefix %s',
77+
split, input_pattern, output_prefix)
78+
79+
# Run the Beam pipeline to read, filter, and write the split.
80+
_ = (
81+
pipeline
82+
| f'ReadFromTFRecord[{split}]' >> beam.io.ReadFromTFRecord(
83+
input_pattern)
84+
| f'FilterExamples[{split}]' >> beam.Filter(filter_fn)
85+
| f'WriteToTFRecord[{split}]' >> beam.io.WriteToTFRecord(
86+
output_prefix,
87+
file_name_suffix='.gz')
88+
)
89+
90+
logging.info('FilterComponent execution completed successfully.')
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright 2026 Google LLC. All Rights Reserved.
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+
"""Tests for tfx.components.experimental.filter.executor."""
15+
16+
import os
17+
from typing import List
18+
import tensorflow as tf
19+
from tfx.components.experimental.filter import executor
20+
21+
from tfx.types import standard_artifacts
22+
from tfx.types import artifact_utils
23+
24+
25+
def dummy_filter_fn(serialized_example: bytes) -> bool:
26+
"""A simple filter function that parses the example and filters by age."""
27+
example = tf.train.Example()
28+
example.ParseFromString(serialized_example)
29+
features = example.features.feature
30+
if 'age' in features:
31+
return features['age'].int64_list.value[0] > 18
32+
return False
33+
34+
35+
class ExecutorTest(tf.test.TestCase):
36+
37+
def setUp(self):
38+
super().setUp()
39+
self._output_data_dir = os.path.join(
40+
os.environ.get('TEST_UNDECLARED_OUTPUTS_DIR', self.get_temp_dir()),
41+
self._testMethodName)
42+
self._input_data_dir = os.path.join(self.get_temp_dir(), 'input')
43+
44+
def _create_test_examples(self, examples_artifact: standard_artifacts.Examples, split_name: str, values: List[int]):
45+
"""Creates a TFRecord file with test examples for the given split."""
46+
split_dir = artifact_utils.get_split_uri([examples_artifact], split_name)
47+
tf.io.gfile.makedirs(split_dir)
48+
file_path = os.path.join(split_dir, 'data.tfrecord.gz')
49+
50+
options = tf.io.TFRecordOptions(compression_type='GZIP')
51+
with tf.io.TFRecordWriter(file_path, options=options) as writer:
52+
for val in values:
53+
example = tf.train.Example()
54+
example.features.feature['age'].int64_list.value.append(val)
55+
writer.write(example.SerializeToString())
56+
57+
def testExecutor(self):
58+
# 1. Prepare input and output examples artifacts.
59+
examples_artifact = standard_artifacts.Examples()
60+
examples_artifact.uri = self._input_data_dir
61+
examples_artifact.split_names = artifact_utils.encode_split_names(
62+
['train', 'eval'])
63+
64+
# 2. Create input data:
65+
# train split has ages [10, 20, 30] -> filtered should have [20, 30]
66+
# eval split has ages [15, 25] -> filtered should have [25]
67+
self._create_test_examples(examples_artifact, 'train', [10, 20, 30])
68+
self._create_test_examples(examples_artifact, 'eval', [15, 25])
69+
70+
filtered_examples_artifact = standard_artifacts.Examples()
71+
filtered_examples_artifact.uri = self._output_data_dir
72+
73+
input_dict = {'examples': [examples_artifact]}
74+
output_dict = {'filtered_examples': [filtered_examples_artifact]}
75+
76+
# Full python import path to the dummy_filter_fn
77+
filter_fn_path = (
78+
'tfx.components.experimental.filter.executor_test.dummy_filter_fn')
79+
80+
exec_properties = {'filter_fn_path': filter_fn_path}
81+
82+
# 3. Run the executor.
83+
filter_executor = executor.Executor()
84+
filter_executor.Do(input_dict, output_dict, exec_properties)
85+
86+
# 4. Verify output splits.
87+
decoded_splits = artifact_utils.decode_split_names(
88+
filtered_examples_artifact.split_names)
89+
self.assertEqual(decoded_splits, ['train', 'eval'])
90+
91+
# 5. Verify the content of the filtered train split.
92+
train_output_dir = artifact_utils.get_split_uri(
93+
[filtered_examples_artifact], 'train')
94+
train_output_files = tf.io.gfile.glob(os.path.join(train_output_dir, '*'))
95+
self.assertNotEmpty(train_output_files)
96+
97+
train_ages = []
98+
# Read the output TFRecords. Beam writes sharded files, so we read all matching files.
99+
for file_path in train_output_files:
100+
raw_dataset = tf.data.TFRecordDataset(file_path, compression_type='GZIP')
101+
for raw_record in raw_dataset:
102+
example = tf.train.Example()
103+
example.ParseFromString(raw_record.numpy())
104+
train_ages.append(example.features.feature['age'].int64_list.value[0])
105+
106+
self.assertCountEqual(train_ages, [20, 30])
107+
108+
# 6. Verify the content of the filtered eval split.
109+
eval_output_dir = artifact_utils.get_split_uri(
110+
[filtered_examples_artifact], 'eval')
111+
eval_output_files = tf.io.gfile.glob(os.path.join(eval_output_dir, '*'))
112+
self.assertNotEmpty(eval_output_files)
113+
114+
eval_ages = []
115+
for file_path in eval_output_files:
116+
raw_dataset = tf.data.TFRecordDataset(file_path, compression_type='GZIP')
117+
for raw_record in raw_dataset:
118+
example = tf.train.Example()
119+
example.ParseFromString(raw_record.numpy())
120+
eval_ages.append(example.features.feature['age'].int64_list.value[0])
121+
122+
self.assertCountEqual(eval_ages, [25])
123+
124+
125+
if __name__ == '__main__':
126+
tf.test.main()

0 commit comments

Comments
 (0)