This is an automated email from the ASF dual-hosted git repository.
damccorm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 6fba3cb34b2 Implement MLTransform One-Hot Encoding benchmark pipeline
(#38404)
6fba3cb34b2 is described below
commit 6fba3cb34b29b49b2fbfe368ffad2a2c175f6fe7
Author: Abdelrahman Ibrahim <[email protected]>
AuthorDate: Fri May 29 17:15:12 2026 +0200
Implement MLTransform One-Hot Encoding benchmark pipeline (#38404)
* Implement MLTransform One-Hot Encoding benchmark pipeline
* added missing title
---
.../beam_Inference_Python_Benchmarks_Dataflow.yml | 13 +
...Dataflow_MLTransform_One_Hot_Encoding_Batch.txt | 36 +++
.test-infra/tools/refresh_looker_metrics.py | 1 +
.../ml_transform/mltransform_one_hot_encoding.py | 266 +++++++++++++++++++++
.../mltransform_one_hot_encoding_test.py | 259 ++++++++++++++++++++
.../transforms/mltransform_tests_requirements.txt | 29 +++
.../mltransform_one_hot_encoding_benchmark.py | 197 +++++++++++++++
website/www/site/content/en/performance/_index.md | 1 +
.../en/performance/mltransformonehot/_index.md | 42 ++++
website/www/site/data/performance.yaml | 17 ++
10 files changed, 861 insertions(+)
diff --git a/.github/workflows/beam_Inference_Python_Benchmarks_Dataflow.yml
b/.github/workflows/beam_Inference_Python_Benchmarks_Dataflow.yml
index 36e962bc2ba..ae52cbb68e1 100644
--- a/.github/workflows/beam_Inference_Python_Benchmarks_Dataflow.yml
+++ b/.github/workflows/beam_Inference_Python_Benchmarks_Dataflow.yml
@@ -95,6 +95,7 @@ jobs:
${{ github.workspace
}}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_Table_Row_Inference_Batch.txt
${{ github.workspace
}}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_Table_Row_Inference_Stream.txt
${{ github.workspace
}}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_Generate_Vocab_Batch.txt
+ ${{ github.workspace
}}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_One_Hot_Encoding_Batch.txt
# The env variables are created and populated in the
test-arguments-action as
"<github.job>_test_arguments_<argument_file_paths_index>"
- name: get current time
run: echo "NOW_UTC=$(date '+%m%d%H%M%S' --utc)" >> $GITHUB_ENV
@@ -226,3 +227,15 @@ jobs:
-PpythonVersion=3.10 \
-PloadTest.requirementsTxtFile=apache_beam/examples/ml_transform/mltransform_generate_vocab_requirements.txt
\
'-PloadTest.args=${{
env.beam_Inference_Python_Benchmarks_Dataflow_test_arguments_11 }}
--job_name=benchmark-tests-mltransform-generate-vocab-batch-${{env.NOW_UTC}}'
+ - name: run MLTransform One-Hot Encoding Batch
+ uses: ./.github/actions/gradle-command-self-hosted-action
+ timeout-minutes: 180
+ with:
+ gradle-command: :sdks:python:apache_beam:testing:load_tests:run
+ arguments: |
+
-PloadTest.mainClass=apache_beam.testing.benchmarks.inference.mltransform_one_hot_encoding_benchmark
\
+ -Prunner=DataflowRunner \
+ -PpythonVersion=3.10 \
+ -PbeamPythonExtra=ml_test \
+
-PloadTest.requirementsTxtFile=apache_beam/ml/transforms/mltransform_tests_requirements.txt
\
+ '-PloadTest.args=${{
env.beam_Inference_Python_Benchmarks_Dataflow_test_arguments_12 }}
--autoscaling_algorithm=NONE --metrics_table=mltransform_one_hot_encoding_batch
--influx_measurement=mltransform_one_hot_encoding_batch
--job_name=benchmark-tests-mltransform-one-hot-encoding-batch-${{env.NOW_UTC}}
--output_file=gs://temp-storage-for-end-to-end-tests/mltransform/one_hot_output_${{env.NOW_UTC}}
--artifact_location=gs://temp-storage-for-end-to-end-tests/mltransform/artifacts
[...]
\ No newline at end of file
diff --git
a/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_One_Hot_Encoding_Batch.txt
b/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_One_Hot_Encoding_Batch.txt
new file mode 100644
index 00000000000..27648d0c0fb
--- /dev/null
+++
b/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_One_Hot_Encoding_Batch.txt
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+--region=us-central1
+--machine_type=n1-standard-2
+--num_workers=50
+--disk_size_gb=50
+--autoscaling_algorithm=NONE
+--staging_location=gs://temp-storage-for-perf-tests/loadtests
+--temp_location=gs://temp-storage-for-perf-tests/loadtests
+--sdk_location=container
+--requirements_file=apache_beam/ml/transforms/mltransform_tests_requirements.txt
+--publish_to_big_query=true
+--metrics_dataset=beam_run_inference
+--metrics_table=mltransform_one_hot_encoding_batch
+--input_options={}
+--influx_measurement=mltransform_one_hot_encoding_batch
+# Note: output_file and artifact_location are set in the workflow with unique
timestamps
+--input_file=gs://apache-beam-ml/testing/inputs/sentences_50k.txt
+--input_format=text
+--categorical_columns=category
+--num_records=1000000
+--runner=DataflowRunner
diff --git a/.test-infra/tools/refresh_looker_metrics.py
b/.test-infra/tools/refresh_looker_metrics.py
index 35122d5afe0..c8d66f4a4bd 100644
--- a/.test-infra/tools/refresh_looker_metrics.py
+++ b/.test-infra/tools/refresh_looker_metrics.py
@@ -46,6 +46,7 @@ LOOKS_TO_DOWNLOAD = [
("96", ["270", "304", "305", "353", "354"]), # Table Row Inference
Sklearn Batch
("106", ["355", "356", "357", "358", "359"]), # Table Row Inference
Sklearn Streaming
("107", ["360", "361", "362", "363", "364"]), # MLTransform Generate
Vocab Batch
+ ("108", ["365", "366", "367", "368", "369"]) # MLTransform One-Hot
Encoding Batch
]
def get_look(id: str) -> models.Look:
diff --git
a/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding.py
b/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding.py
new file mode 100644
index 00000000000..5db03c31f79
--- /dev/null
+++
b/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding.py
@@ -0,0 +1,266 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Categorical encoding pipeline using MLTransform for batch processing.
+
+This pipeline demonstrates MLTransform's ComputeAndApplyVocabulary transform
+for categorical feature encoding. It can either read input data from a file
+or generate synthetic test data, computes vocabulary on categorical columns,
+and converts categorical values to integer indices.
+
+Example usage with input file:
+ python mltransform_one_hot_encoding.py \
+ --input_file=gs://bucket/input.jsonl \
+ --output_file=gs://bucket/output.jsonl \
+ --artifact_location=gs://bucket/artifacts \
+ --categorical_columns=category \
+ --runner=DataflowRunner \
+ --project=PROJECT \
+ --region=us-central1 \
+ --temp_location=gs://bucket/temp
+
+Example usage with synthetic data:
+ python mltransform_one_hot_encoding.py \
+ --output_file=gs://bucket/output.jsonl \
+ --categorical_columns=category \
+ --num_records=100000 \
+ --runner=DataflowRunner \
+ --project=PROJECT \
+ --region=us-central1
+"""
+
+import argparse
+import json
+import logging
+import tempfile
+from typing import Any
+
+import apache_beam as beam
+from apache_beam.ml.transforms.base import MLTransform
+from apache_beam.ml.transforms.tft import ComputeAndApplyVocabulary
+from apache_beam.runners.runner import PipelineResult
+
+
+def parse_json_line(line: str) -> dict[str, Any]:
+ """Parse a JSON line into a dictionary."""
+ try:
+ return json.loads(line)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Failed to parse JSON line: {line[:200]}...") from e
+
+
+def parse_text_line(line: str,
+ categorical_columns: list[str]) -> dict[str, Any]:
+ """Parse plain text line into the first categorical column."""
+ text_value = line.strip()
+ if not text_value:
+ text_value = 'unknown'
+ return {categorical_columns[0]: text_value}
+
+
+def format_json_output(element: Any) -> str:
+ """Format output element as JSON string."""
+ def to_json_compatible(value: Any) -> Any:
+ """Recursively convert non-JSON types (e.g. numpy arrays/scalars)."""
+ if isinstance(value, dict):
+ return {k: to_json_compatible(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [to_json_compatible(v) for v in value]
+
+ # MLTransform outputs may include numpy scalar/ndarray values.
+ if hasattr(value, 'tolist'):
+ return to_json_compatible(value.tolist())
+ if hasattr(value, 'item'):
+ try:
+ return to_json_compatible(value.item())
+ except (TypeError, ValueError):
+ pass
+ return value
+
+ if hasattr(element, 'as_dict'):
+ return json.dumps(to_json_compatible(element.as_dict()))
+ if hasattr(element, '_asdict'):
+ return json.dumps(to_json_compatible(element._asdict()))
+ return json.dumps(to_json_compatible(dict(element)))
+
+
+def generate_synthetic_record(index: int,
+ categorical_columns: list[str]) -> dict[str,
str]:
+ """Generate a deterministic synthetic record with categorical values."""
+ categories = [
+ 'electronics',
+ 'clothing',
+ 'food',
+ 'books',
+ 'sports',
+ 'home',
+ 'toys',
+ 'health',
+ 'automotive',
+ 'garden'
+ ]
+ colors = [
+ 'red',
+ 'blue',
+ 'green',
+ 'yellow',
+ 'black',
+ 'white',
+ 'purple',
+ 'orange',
+ 'pink',
+ 'gray'
+ ]
+ sizes = ['small', 'medium', 'large', 'xlarge', 'tiny', 'huge']
+
+ record = {}
+ for col in categorical_columns:
+ if col.lower() in ['category', 'type', 'product']:
+ record[col] = categories[index % len(categories)]
+ elif col.lower() in ['color', 'colour']:
+ record[col] = colors[index % len(colors)]
+ elif col.lower() in ['size', 'dimension']:
+ record[col] = sizes[index % len(sizes)]
+ else:
+ # Default to categories for unknown columns
+ record[col] = categories[index % len(categories)]
+ return record
+
+
+def run(
+ argv=None,
+ save_main_session=True,
+ test_pipeline=None) -> PipelineResult | None:
+ """Run the categorical encoding pipeline."""
+ known_args, pipeline_args = parse_known_args(argv)
+
+ categorical_columns = [
+ col.strip() for col in known_args.categorical_columns.split(',')
+ ]
+
+ if not categorical_columns or not categorical_columns[0]:
+ raise ValueError("At least one categorical column must be specified")
+
+ if not known_args.output_file:
+ raise ValueError("--output_file is required")
+
+ # Create artifact location if not provided
+ artifact_location = known_args.artifact_location
+ if not artifact_location:
+ artifact_location = tempfile.mkdtemp()
+ logging.info("Using temporary artifact location: %s", artifact_location)
+
+ pipeline_options = beam.options.pipeline_options.PipelineOptions(
+ pipeline_args)
+ pipeline_options.view_as(
+ beam.options.pipeline_options.SetupOptions
+ ).save_main_session = save_main_session
+
+ pipeline = test_pipeline or beam.Pipeline(options=pipeline_options)
+
+ # Use synthetic data or read from file
+ if known_args.input_file:
+ # Read and parse input data from file
+ if known_args.input_format == 'jsonl':
+ parse_input_fn = parse_json_line
+ else:
+ if len(categorical_columns) > 1:
+ logging.warning(
+ 'Input format is "text" but multiple categorical columns are '
+ 'specified. Only the first column "%s" will be used for parsing.',
+ categorical_columns[0])
+ parse_input_fn = lambda line: parse_text_line(line, categorical_columns)
+ raw_data = (
+ pipeline
+ | 'ReadFromJSONL' >> beam.io.ReadFromText(known_args.input_file)
+ | 'ParseInput' >> beam.Map(parse_input_fn))
+ else:
+ # Generate synthetic data
+ num_records = known_args.num_records or 100000
+ logging.info("Generating %d synthetic records", num_records)
+
+ raw_data = (
+ pipeline
+ | 'GenerateSyntheticIndexes' >> beam.Create(range(num_records))
+ | 'BuildSyntheticRecord' >> beam.Map(
+ lambda idx: generate_synthetic_record(idx, categorical_columns)))
+
+ # Build MLTransform with ComputeAndApplyVocabulary
+ ml_transform = MLTransform(
+ write_artifact_location=artifact_location,
+ ).with_transform(
+ ComputeAndApplyVocabulary(
+ columns=categorical_columns, vocab_filename='vocab_onehot'))
+
+ # Apply MLTransform
+ transformed_data = (
+ raw_data
+ | 'ValidateAndFilterColumns' >> beam.Filter(
+ lambda element: all(col in element for col in categorical_columns))
+ | 'MLTransform' >> ml_transform
+ | 'FormatOutput' >> beam.Map(format_json_output))
+
+ # Write output
+ _ = (
+ transformed_data
+ | 'WriteToJSONL' >> beam.io.WriteToText(
+ known_args.output_file, file_name_suffix='.jsonl'))
+
+ result = pipeline.run()
+ return result
+
+
+def parse_known_args(argv):
+ """Parse command-line arguments."""
+ parser = argparse.ArgumentParser(
+ description='Categorical encoding pipeline using MLTransform')
+
+ parser.add_argument(
+ '--input_file',
+ help='Input JSONL file path (local or GCS). '
+ 'If not provided, synthetic data will be generated.')
+ parser.add_argument(
+ '--input_format',
+ choices=['jsonl', 'text'],
+ default='jsonl',
+ help='Input file format for --input_file. Use jsonl for JSON lines '
+ 'or text for plain text lines (default: jsonl).')
+ parser.add_argument(
+ '--output_file',
+ required=True,
+ help='Output file prefix for encoded results (JSONL format)')
+ parser.add_argument(
+ '--artifact_location',
+ help='GCS or local path to store MLTransform artifacts '
+ '(vocabulary files). If not provided, a temp location is used.')
+ parser.add_argument(
+ '--categorical_columns',
+ required=True,
+ help='Comma-separated list of categorical column names to encode')
+ parser.add_argument(
+ '--num_records',
+ type=int,
+ default=100000,
+ help='Number of synthetic records to generate if --input_file is not '
+ 'provided (default: 100000)')
+
+ return parser.parse_known_args(argv)
+
+
+if __name__ == '__main__':
+ logging.getLogger().setLevel(logging.INFO)
+ run()
diff --git
a/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding_test.py
b/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding_test.py
new file mode 100644
index 00000000000..e072a367dc3
--- /dev/null
+++
b/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding_test.py
@@ -0,0 +1,259 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Tests for mltransform_one_hot_encoding pipeline."""
+
+import json
+import logging
+import os
+import tempfile
+import unittest
+from glob import glob
+from typing import Any
+
+import pytest
+
+try:
+ from apache_beam.examples.ml_transform import mltransform_one_hot_encoding
+ from apache_beam.testing.test_pipeline import TestPipeline
+except ImportError: # pylint: disable=bare-except
+ raise unittest.SkipTest('tensorflow_transform is not installed.')
+
+
+def create_test_input_data() -> list[dict[str, Any]]:
+ """Create sample test data for one-hot encoding."""
+ return [
+ {
+ 'category': 'electronics', 'color': 'red', 'size': 'small'
+ },
+ {
+ 'category': 'clothing', 'color': 'blue', 'size': 'medium'
+ },
+ {
+ 'category': 'electronics', 'color': 'green', 'size': 'large'
+ },
+ {
+ 'category': 'food', 'color': 'red', 'size': 'small'
+ },
+ {
+ 'category': 'clothing', 'color': 'blue', 'size': 'medium'
+ },
+ ]
+
+
+class OneHotEncodingPipelineTest(unittest.TestCase):
+ """Unit and integration tests for one-hot encoding pipeline."""
+ def setUp(self):
+ """Set up test fixtures."""
+ self.test_dir = tempfile.mkdtemp()
+ self.input_file = os.path.join(self.test_dir, 'input.jsonl')
+ self.output_prefix = os.path.join(self.test_dir, 'output')
+ self.artifact_location = os.path.join(self.test_dir, 'artifacts')
+
+ # Create test input file
+ test_data = create_test_input_data()
+ with open(self.input_file, 'w', encoding='utf-8') as f:
+ for record in test_data:
+ f.write(json.dumps(record) + '\n')
+
+ def tearDown(self):
+ """Clean up test fixtures."""
+ import shutil
+ shutil.rmtree(self.test_dir, ignore_errors=True)
+
+ def test_parse_json_line_valid(self):
+ """Test parsing valid JSON lines."""
+ line = '{"category": "electronics", "color": "red"}'
+ result = mltransform_one_hot_encoding.parse_json_line(line)
+ self.assertEqual(result['category'], 'electronics')
+ self.assertEqual(result['color'], 'red')
+
+ def test_parse_json_line_invalid(self):
+ """Test parsing invalid JSON lines raises ValueError."""
+ with self.assertRaises(ValueError):
+ mltransform_one_hot_encoding.parse_json_line('not valid json')
+
+ def test_format_json_output_with_row(self):
+ """Test formatting beam.Row output as JSON."""
+ import apache_beam as beam
+ row = beam.Row(category='test', value=123)
+ result = mltransform_one_hot_encoding.format_json_output(row)
+ parsed = json.loads(result)
+ self.assertEqual(parsed['category'], 'test')
+ self.assertEqual(parsed['value'], 123)
+
+ def test_format_json_output_with_dict(self):
+ """Test formatting dict output as JSON."""
+ element = {'category': 'test', 'value': 123}
+ result = mltransform_one_hot_encoding.format_json_output(element)
+ parsed = json.loads(result)
+ self.assertEqual(parsed['category'], 'test')
+ self.assertEqual(parsed['value'], 123)
+
+ @pytest.mark.uses_tft
+ def test_end_to_end_pipeline_local(self):
+ """Integration test running the full pipeline locally."""
+ extra_opts = {
+ 'input_file': self.input_file,
+ 'output_file': self.output_prefix,
+ 'artifact_location': self.artifact_location,
+ 'categorical_columns': 'category,color,size',
+ }
+
+ with TestPipeline() as pipeline:
+ mltransform_one_hot_encoding.run(
+ argv=pipeline.get_full_options_as_args(**extra_opts),
+ test_pipeline=pipeline)
+
+ # Verify output shards exist.
+ output_files = glob(self.output_prefix + '*.jsonl')
+ self.assertTrue(
+ output_files, f"Output files not found for: {self.output_prefix}")
+
+ # Verify output content
+ lines = []
+ for output_file in output_files:
+ with open(output_file, 'r', encoding='utf-8') as f:
+ lines.extend(line.strip() for line in f if line.strip())
+
+ self.assertEqual(len(lines), 5)
+
+ # Parse and verify structure
+ for line in lines:
+ record = json.loads(line)
+ # Should have original columns plus one-hot encoded versions
+ self.assertIn('category', record)
+ self.assertIn('color', record)
+ self.assertIn('size', record)
+
+ @pytest.mark.uses_tft
+ def test_pipeline_with_missing_columns(self):
+ """Test pipeline handles records with missing columns gracefully."""
+ # Create input with some missing columns
+ mixed_data = [
+ {
+ 'category': 'electronics', 'color': 'red', 'size': 'small'
+ },
+ {
+ 'category': 'clothing', 'color': 'blue'
+ }, # missing size
+ {
+ 'category': 'food'
+ }, # missing color and size
+ ]
+
+ input_file = os.path.join(self.test_dir, 'mixed_input.jsonl')
+ with open(input_file, 'w', encoding='utf-8') as f:
+ for record in mixed_data:
+ f.write(json.dumps(record) + '\n')
+
+ extra_opts = {
+ 'input_file': input_file,
+ 'output_file': os.path.join(self.test_dir, 'mixed_output'),
+ 'artifact_location': os.path.join(self.test_dir, 'mixed_artifacts'),
+ 'categorical_columns': 'category,color,size',
+ }
+
+ with TestPipeline() as pipeline:
+ mltransform_one_hot_encoding.run(
+ argv=pipeline.get_full_options_as_args(**extra_opts),
+ test_pipeline=pipeline)
+
+ # Only first record should be processed
+ output_files = glob(os.path.join(self.test_dir, 'mixed_output*.jsonl'))
+ lines = []
+ for output_file in output_files:
+ with open(output_file, 'r', encoding='utf-8') as f:
+ lines.extend(line.strip() for line in f if line.strip())
+
+ self.assertEqual(len(lines), 1)
+ record = json.loads(lines[0])
+ self.assertEqual(record['category'], 'electronics')
+
+ def test_cli_synthetic_data_no_input(self):
+ """Test pipeline works without input file using synthetic data."""
+ # Should not raise error when input_file is missing (uses synthetic data)
+ with tempfile.TemporaryDirectory() as tmpdir:
+ output_file = os.path.join(tmpdir, 'output')
+ artifact_location = os.path.join(tmpdir, 'artifacts')
+
+ with TestPipeline() as pipeline:
+ # Should work without input_file (uses synthetic data)
+ mltransform_one_hot_encoding.run(
+ argv=pipeline.get_full_options_as_args(
+ output_file=output_file,
+ artifact_location=artifact_location,
+ categorical_columns='category',
+ num_records=100),
+ test_pipeline=pipeline)
+
+ def test_cli_validation_missing_output(self):
+ """Test CLI argument validation for missing output file."""
+ with self.assertRaises(ValueError) as context:
+ mltransform_one_hot_encoding.run(
+ argv=['--input_file=/tmp/in.jsonl',
'--categorical_columns=category'])
+ self.assertIn('output_file', str(context.exception).lower())
+
+ def test_cli_validation_empty_columns(self):
+ """Test CLI argument validation for empty columns."""
+ with self.assertRaises(ValueError) as context:
+ mltransform_one_hot_encoding.run(
+ argv=[
+ '--input_file=/tmp/in.jsonl',
+ '--output_file=/tmp/out.jsonl',
+ '--categorical_columns='
+ ])
+ self.assertIn('categorical', str(context.exception).lower())
+
+
+class OneHotEncodingCLITest(unittest.TestCase):
+ """Tests for CLI argument handling."""
+ def test_parse_known_args_basic(self):
+ """Test basic argument parsing."""
+ args, _ = mltransform_one_hot_encoding.parse_known_args([
+ '--input_file=/tmp/in.jsonl',
+ '--output_file=/tmp/out.jsonl',
+ '--categorical_columns=category,color',
+ ])
+ self.assertEqual(args.input_file, '/tmp/in.jsonl')
+ self.assertEqual(args.output_file, '/tmp/out.jsonl')
+ self.assertEqual(args.categorical_columns, 'category,color')
+
+ def test_parse_known_args_with_artifact(self):
+ """Test argument parsing with artifact location."""
+ args, _ = mltransform_one_hot_encoding.parse_known_args([
+ '--input_file=gs://bucket/in.jsonl',
+ '--output_file=gs://bucket/out',
+ '--artifact_location=gs://bucket/artifacts',
+ '--categorical_columns=size,color',
+ ])
+ self.assertEqual(args.artifact_location, 'gs://bucket/artifacts')
+
+ def test_parse_known_args_multiple_columns(self):
+ """Test parsing multiple categorical columns."""
+ args, _ = mltransform_one_hot_encoding.parse_known_args([
+ '--input_file=in.jsonl',
+ '--output_file=out.jsonl',
+ '--categorical_columns=col1,col2,col3,col4',
+ ])
+ columns = [c.strip() for c in args.categorical_columns.split(',')]
+ self.assertEqual(columns, ['col1', 'col2', 'col3', 'col4'])
+
+
+if __name__ == '__main__':
+ logging.getLogger().setLevel(logging.INFO)
+ unittest.main()
diff --git
a/sdks/python/apache_beam/ml/transforms/mltransform_tests_requirements.txt
b/sdks/python/apache_beam/ml/transforms/mltransform_tests_requirements.txt
new file mode 100644
index 00000000000..9f37e070a60
--- /dev/null
+++ b/sdks/python/apache_beam/ml/transforms/mltransform_tests_requirements.txt
@@ -0,0 +1,29 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# Requirements for MLTransform tests on Dataflow workers.
+# Keep this aligned with CloudML benchmark stack to avoid worker import errors.
+dill==0.4.1
+tfx_bsl==1.16.1
+tensorflow-transform==1.16.0
+tensorflow>=2.16,<2.17
+numpy>=1.22.0,<2.0
+tensorflow-metadata>=1.16.1,<1.17.0
+pyarrow>=10,<11
+tensorflow-serving-api>=2.16.1,<2.20
+tf-keras>=2.16.0,<2.17
+google-cloud-monitoring>=2.27.0
diff --git
a/sdks/python/apache_beam/testing/benchmarks/inference/mltransform_one_hot_encoding_benchmark.py
b/sdks/python/apache_beam/testing/benchmarks/inference/mltransform_one_hot_encoding_benchmark.py
new file mode 100644
index 00000000000..e80fca63335
--- /dev/null
+++
b/sdks/python/apache_beam/testing/benchmarks/inference/mltransform_one_hot_encoding_benchmark.py
@@ -0,0 +1,197 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# pytype: skip-file
+
+"""Benchmark test for MLTransform One-Hot Encoding pipeline.
+
+This benchmark measures the performance of MLTransform for one-hot encoding
+categorical features, including throughput, latency, and cost metrics on
+Dataflow.
+"""
+
+import logging
+
+from google.cloud import monitoring_v3
+from google.protobuf.duration_pb2 import Duration
+
+from apache_beam.examples.ml_transform import mltransform_one_hot_encoding
+from apache_beam.options.pipeline_options import DebugOptions
+from apache_beam.options.pipeline_options import GoogleCloudOptions
+from apache_beam.options.pipeline_options import SetupOptions
+from apache_beam.options.pipeline_options import StandardOptions
+from apache_beam.options.pipeline_options import WorkerOptions
+from apache_beam.testing.load_tests.dataflow_cost_benchmark import
DataflowCostBenchmark
+from apache_beam.testing.load_tests.load_test import LoadTestOptions
+
+
+class MLTransformOneHotEncodingOptions(
+ LoadTestOptions,
+ StandardOptions,
+ GoogleCloudOptions,
+ WorkerOptions,
+ DebugOptions,
+ SetupOptions,
+):
+ """Pipeline options for MLTransform One-Hot Encoding benchmark."""
+ @classmethod
+ def _add_argparse_args(cls, parser):
+ parser.add_argument(
+ '--input_file',
+ default='',
+ help='Input JSONL/text file path for benchmark data.')
+ parser.add_argument(
+ '--input_format',
+ choices=['jsonl', 'text'],
+ default='jsonl',
+ help='Input file format for input_file: jsonl or text.')
+ parser.add_argument(
+ '--output_file',
+ required=True,
+ help='Output file path for encoded results')
+ parser.add_argument(
+ '--artifact_location',
+ required=True,
+ help='GCS path to store MLTransform artifacts')
+ parser.add_argument(
+ '--categorical_columns',
+ default='category',
+ help='Comma-separated list of categorical column names to encode')
+ parser.add_argument(
+ '--num_records',
+ type=int,
+ default=100000,
+ help='Number of synthetic records to generate')
+
+
+class MLTransformOneHotEncodingBenchmarkTest(DataflowCostBenchmark):
+ """Benchmark for MLTransform One-Hot Encoding on Dataflow.
+
+ This benchmark measures:
+ - Throughput: Elements processed per second
+ - Latency: Time to process input records
+ - Cost: Estimated cost on Dataflow
+
+ The pcollection is chosen to capture the output of the MLTransform
+ step where one-hot encoding is applied.
+ """
+ options_class = MLTransformOneHotEncodingOptions
+
+ def __init__(self):
+ self.metrics_namespace = 'BeamML_MLTransform'
+ # Use the output of MLTransform step for throughput measurement
+ # This captures the processed data after vocabulary encoding
+ super().__init__(
+ metrics_namespace=self.metrics_namespace,
+ is_streaming=False,
+ pcollection='FormatOutput.out0')
+
+ def test(self):
+ """Execute the one-hot encoding pipeline for benchmarking."""
+ extra_opts = {}
+
+ extra_opts['output_file'] = self.pipeline.get_option('output_file')
+ extra_opts['artifact_location'] = self.pipeline.get_option(
+ 'artifact_location')
+ extra_opts['categorical_columns'] = (
+ self.pipeline.get_option('categorical_columns') or 'category')
+
+ input_file = self.pipeline.get_option('input_file')
+ if input_file:
+ extra_opts['input_file'] = input_file
+ extra_opts['input_format'] = (
+ self.pipeline.get_option('input_format') or 'jsonl')
+ else:
+ # Handle synthetic data generation
+ num_records = self.pipeline.get_option('num_records')
+ if num_records:
+ extra_opts['num_records'] = int(num_records)
+
+ self.result = mltransform_one_hot_encoding.run(
+ self.pipeline.get_full_options_as_args(**extra_opts),
+ test_pipeline=self.pipeline)
+
+ def _get_throughput_metrics(
+ self,
+ project: str,
+ job_id: str,
+ start_time: str,
+ end_time: str,
+ pcollection_name: str | None = None,
+ ) -> dict[str, float]:
+ """Get throughput metrics with runner-v2-friendly fallbacks."""
+ candidate_pcollections = []
+ if pcollection_name:
+ candidate_pcollections.append(pcollection_name)
+ candidate_pcollections.extend([
+ self.pcollection,
+ 'MLTransform.out0',
+ 'FormatOutput.out0',
+ ])
+
+ # Deduplicate while preserving order.
+ seen = set()
+ unique_candidates = []
+ for name in candidate_pcollections:
+ if name and name not in seen:
+ seen.add(name)
+ unique_candidates.append(name)
+
+ for name in unique_candidates:
+ metrics = super()._get_throughput_metrics(
+ project, job_id, start_time, end_time, pcollection_name=name)
+ if (metrics.get('AvgThroughputBytes', 0) > 0 or
+ metrics.get('AvgThroughputElements', 0) > 0):
+ return metrics
+
+ # Final fallback: aggregate job-level throughput without pcollection label.
+ interval = monitoring_v3.TimeInterval(
+ start_time=start_time, end_time=end_time)
+ aggregation = monitoring_v3.Aggregation(
+ alignment_period=Duration(seconds=60),
+ per_series_aligner=monitoring_v3.Aggregation.Aligner.ALIGN_MEAN)
+ requests = {
+ "Bytes": monitoring_v3.ListTimeSeriesRequest(
+ name=f"projects/{project}",
+ filter=(
+
'metric.type="dataflow.googleapis.com/job/estimated_byte_count" '
+ f'AND metric.labels.job_id="{job_id}"'),
+ interval=interval,
+ aggregation=aggregation),
+ "Elements": monitoring_v3.ListTimeSeriesRequest(
+ name=f"projects/{project}",
+ filter=(
+ 'metric.type="dataflow.googleapis.com/job/element_count" '
+ f'AND metric.labels.job_id="{job_id}"'),
+ interval=interval,
+ aggregation=aggregation),
+ }
+
+ fallback_metrics = {}
+ for key, req in requests.items():
+ time_series = self.monitoring_client.list_time_series(request=req)
+ values = [
+ point.value.double_value for series in time_series
+ for point in series.points
+ ]
+ fallback_metrics[f"AvgThroughput{key}"] = (
+ sum(values) / len(values) if values else 0.0)
+ return fallback_metrics
+
+
+if __name__ == '__main__':
+ logging.basicConfig(level=logging.INFO)
+ MLTransformOneHotEncodingBenchmarkTest().run()
diff --git a/website/www/site/content/en/performance/_index.md
b/website/www/site/content/en/performance/_index.md
index 350524a3c86..a0eaba2aa0e 100644
--- a/website/www/site/content/en/performance/_index.md
+++ b/website/www/site/content/en/performance/_index.md
@@ -60,3 +60,4 @@ See the following pages for performance measures recorded
when running various B
- [VLLM Gemma Batch Completion Tesla T4 GPU](/performance/vllmgemmabatchtesla)
- [Table Row Inference Sklearn Batch](/performance/tablerowinference)
- [MLTransform Generate Vocab (batch)](/performance/mltransformvocab)
+- [MLTransform One-Hot Encoding](/performance/mltransformonehot)
diff --git
a/website/www/site/content/en/performance/mltransformonehot/_index.md
b/website/www/site/content/en/performance/mltransformonehot/_index.md
new file mode 100644
index 00000000000..2d641bcfb66
--- /dev/null
+++ b/website/www/site/content/en/performance/mltransformonehot/_index.md
@@ -0,0 +1,42 @@
+---
+title: "MLTransform One-Hot Encoding Performance"
+---
+
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# MLTransform One-Hot Encoding Performance
+
+**Pipeline**: MLTransform One-Hot Encoding for Categorical Features
+**Type**: Batch only
+**Host**: 50 × n1-standard-2 (2 vCPUs, 7.5 GB RAM)
+
+The following graphs show various metrics when running the MLTransform One-Hot
+Encoding pipeline using Apache Beam's MLTransform TFT integration.
+See the [glossary](/performance/glossary) for definitions.
+
+Full pipeline implementation is available
+[here](https://github.com/apache/beam/blob/master/sdks/python/apache_beam/examples/ml_transform/mltransform_one_hot_encoding.py).
+
+## What is the estimated cost to run the pipeline?
+
+{{< performance_looks io="mltransformonehot" read_or_write="write"
section="cost" >}}
+
+## How has various metrics changed when running the pipeline for different
Beam SDK versions?
+
+{{< performance_looks io="mltransformonehot" read_or_write="write"
section="version" >}}
+
+## How has various metrics changed over time when running the pipeline?
+
+{{< performance_looks io="mltransformonehot" read_or_write="write"
section="date" >}}
diff --git a/website/www/site/data/performance.yaml
b/website/www/site/data/performance.yaml
index 9725a4af99a..42fb5bee92f 100644
--- a/website/www/site/data/performance.yaml
+++ b/website/www/site/data/performance.yaml
@@ -299,3 +299,20 @@ looks:
title: AvgThroughputBytesPerSec by Version
- id: cC75NnCbQT3mQmKVHtDxzptXpwPb64qz
title: AvgThroughputElementsPerSec by Version
+ mltransformonehot:
+ write:
+ folder: 108
+ cost:
+ - id: 37DYwfbr5y4gt7g7g7RzRSzGTjd4Jjbj
+ title: RunTime and EstimatedCost
+ date:
+ - id: trcXrBCyPjYj2jTGc3px3d72xMXPCmZb
+ title: AvgThroughputBytesPerSec by Date
+ - id: yczZ4G8rYcP3SHjtQXz7p4BdRhGcXydx
+ title: AvgThroughputElementsPerSec by Date
+ version:
+ - id: mHtwwXKndpJVPjnkcHqRZpfPzfzHtT38
+ title: AvgThroughputBytesPerSec by Version
+ - id: Cn5FsXkdy2ZXCxCJshSCxcjsTW3TXf3c
+ title: AvgThroughputElementsPerSec by Version
+