Skip to content

Commit 6fba3cb

Browse files
authored
Implement MLTransform One-Hot Encoding benchmark pipeline (#38404)
* Implement MLTransform One-Hot Encoding benchmark pipeline * added missing title
1 parent 5a5ef0c commit 6fba3cb

10 files changed

Lines changed: 861 additions & 0 deletions

File tree

.github/workflows/beam_Inference_Python_Benchmarks_Dataflow.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ jobs:
9595
${{ github.workspace }}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_Table_Row_Inference_Batch.txt
9696
${{ github.workspace }}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_Table_Row_Inference_Stream.txt
9797
${{ github.workspace }}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_Generate_Vocab_Batch.txt
98+
${{ github.workspace }}/.github/workflows/load-tests-pipeline-options/beam_Inference_Python_Benchmarks_Dataflow_MLTransform_One_Hot_Encoding_Batch.txt
9899
# The env variables are created and populated in the test-arguments-action as "<github.job>_test_arguments_<argument_file_paths_index>"
99100
- name: get current time
100101
run: echo "NOW_UTC=$(date '+%m%d%H%M%S' --utc)" >> $GITHUB_ENV
@@ -226,3 +227,15 @@ jobs:
226227
-PpythonVersion=3.10 \
227228
-PloadTest.requirementsTxtFile=apache_beam/examples/ml_transform/mltransform_generate_vocab_requirements.txt \
228229
'-PloadTest.args=${{ env.beam_Inference_Python_Benchmarks_Dataflow_test_arguments_11 }} --job_name=benchmark-tests-mltransform-generate-vocab-batch-${{env.NOW_UTC}}'
230+
- name: run MLTransform One-Hot Encoding Batch
231+
uses: ./.github/actions/gradle-command-self-hosted-action
232+
timeout-minutes: 180
233+
with:
234+
gradle-command: :sdks:python:apache_beam:testing:load_tests:run
235+
arguments: |
236+
-PloadTest.mainClass=apache_beam.testing.benchmarks.inference.mltransform_one_hot_encoding_benchmark \
237+
-Prunner=DataflowRunner \
238+
-PpythonVersion=3.10 \
239+
-PbeamPythonExtra=ml_test \
240+
-PloadTest.requirementsTxtFile=apache_beam/ml/transforms/mltransform_tests_requirements.txt \
241+
'-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_${{env.NOW_UTC}}'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
--region=us-central1
18+
--machine_type=n1-standard-2
19+
--num_workers=50
20+
--disk_size_gb=50
21+
--autoscaling_algorithm=NONE
22+
--staging_location=gs://temp-storage-for-perf-tests/loadtests
23+
--temp_location=gs://temp-storage-for-perf-tests/loadtests
24+
--sdk_location=container
25+
--requirements_file=apache_beam/ml/transforms/mltransform_tests_requirements.txt
26+
--publish_to_big_query=true
27+
--metrics_dataset=beam_run_inference
28+
--metrics_table=mltransform_one_hot_encoding_batch
29+
--input_options={}
30+
--influx_measurement=mltransform_one_hot_encoding_batch
31+
# Note: output_file and artifact_location are set in the workflow with unique timestamps
32+
--input_file=gs://apache-beam-ml/testing/inputs/sentences_50k.txt
33+
--input_format=text
34+
--categorical_columns=category
35+
--num_records=1000000
36+
--runner=DataflowRunner

.test-infra/tools/refresh_looker_metrics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
("96", ["270", "304", "305", "353", "354"]), # Table Row Inference Sklearn Batch
4747
("106", ["355", "356", "357", "358", "359"]), # Table Row Inference Sklearn Streaming
4848
("107", ["360", "361", "362", "363", "364"]), # MLTransform Generate Vocab Batch
49+
("108", ["365", "366", "367", "368", "369"]) # MLTransform One-Hot Encoding Batch
4950
]
5051

5152
def get_look(id: str) -> models.Look:
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
"""Categorical encoding pipeline using MLTransform for batch processing.
19+
20+
This pipeline demonstrates MLTransform's ComputeAndApplyVocabulary transform
21+
for categorical feature encoding. It can either read input data from a file
22+
or generate synthetic test data, computes vocabulary on categorical columns,
23+
and converts categorical values to integer indices.
24+
25+
Example usage with input file:
26+
python mltransform_one_hot_encoding.py \
27+
--input_file=gs://bucket/input.jsonl \
28+
--output_file=gs://bucket/output.jsonl \
29+
--artifact_location=gs://bucket/artifacts \
30+
--categorical_columns=category \
31+
--runner=DataflowRunner \
32+
--project=PROJECT \
33+
--region=us-central1 \
34+
--temp_location=gs://bucket/temp
35+
36+
Example usage with synthetic data:
37+
python mltransform_one_hot_encoding.py \
38+
--output_file=gs://bucket/output.jsonl \
39+
--categorical_columns=category \
40+
--num_records=100000 \
41+
--runner=DataflowRunner \
42+
--project=PROJECT \
43+
--region=us-central1
44+
"""
45+
46+
import argparse
47+
import json
48+
import logging
49+
import tempfile
50+
from typing import Any
51+
52+
import apache_beam as beam
53+
from apache_beam.ml.transforms.base import MLTransform
54+
from apache_beam.ml.transforms.tft import ComputeAndApplyVocabulary
55+
from apache_beam.runners.runner import PipelineResult
56+
57+
58+
def parse_json_line(line: str) -> dict[str, Any]:
59+
"""Parse a JSON line into a dictionary."""
60+
try:
61+
return json.loads(line)
62+
except json.JSONDecodeError as e:
63+
raise ValueError(f"Failed to parse JSON line: {line[:200]}...") from e
64+
65+
66+
def parse_text_line(line: str,
67+
categorical_columns: list[str]) -> dict[str, Any]:
68+
"""Parse plain text line into the first categorical column."""
69+
text_value = line.strip()
70+
if not text_value:
71+
text_value = 'unknown'
72+
return {categorical_columns[0]: text_value}
73+
74+
75+
def format_json_output(element: Any) -> str:
76+
"""Format output element as JSON string."""
77+
def to_json_compatible(value: Any) -> Any:
78+
"""Recursively convert non-JSON types (e.g. numpy arrays/scalars)."""
79+
if isinstance(value, dict):
80+
return {k: to_json_compatible(v) for k, v in value.items()}
81+
if isinstance(value, (list, tuple)):
82+
return [to_json_compatible(v) for v in value]
83+
84+
# MLTransform outputs may include numpy scalar/ndarray values.
85+
if hasattr(value, 'tolist'):
86+
return to_json_compatible(value.tolist())
87+
if hasattr(value, 'item'):
88+
try:
89+
return to_json_compatible(value.item())
90+
except (TypeError, ValueError):
91+
pass
92+
return value
93+
94+
if hasattr(element, 'as_dict'):
95+
return json.dumps(to_json_compatible(element.as_dict()))
96+
if hasattr(element, '_asdict'):
97+
return json.dumps(to_json_compatible(element._asdict()))
98+
return json.dumps(to_json_compatible(dict(element)))
99+
100+
101+
def generate_synthetic_record(index: int,
102+
categorical_columns: list[str]) -> dict[str, str]:
103+
"""Generate a deterministic synthetic record with categorical values."""
104+
categories = [
105+
'electronics',
106+
'clothing',
107+
'food',
108+
'books',
109+
'sports',
110+
'home',
111+
'toys',
112+
'health',
113+
'automotive',
114+
'garden'
115+
]
116+
colors = [
117+
'red',
118+
'blue',
119+
'green',
120+
'yellow',
121+
'black',
122+
'white',
123+
'purple',
124+
'orange',
125+
'pink',
126+
'gray'
127+
]
128+
sizes = ['small', 'medium', 'large', 'xlarge', 'tiny', 'huge']
129+
130+
record = {}
131+
for col in categorical_columns:
132+
if col.lower() in ['category', 'type', 'product']:
133+
record[col] = categories[index % len(categories)]
134+
elif col.lower() in ['color', 'colour']:
135+
record[col] = colors[index % len(colors)]
136+
elif col.lower() in ['size', 'dimension']:
137+
record[col] = sizes[index % len(sizes)]
138+
else:
139+
# Default to categories for unknown columns
140+
record[col] = categories[index % len(categories)]
141+
return record
142+
143+
144+
def run(
145+
argv=None,
146+
save_main_session=True,
147+
test_pipeline=None) -> PipelineResult | None:
148+
"""Run the categorical encoding pipeline."""
149+
known_args, pipeline_args = parse_known_args(argv)
150+
151+
categorical_columns = [
152+
col.strip() for col in known_args.categorical_columns.split(',')
153+
]
154+
155+
if not categorical_columns or not categorical_columns[0]:
156+
raise ValueError("At least one categorical column must be specified")
157+
158+
if not known_args.output_file:
159+
raise ValueError("--output_file is required")
160+
161+
# Create artifact location if not provided
162+
artifact_location = known_args.artifact_location
163+
if not artifact_location:
164+
artifact_location = tempfile.mkdtemp()
165+
logging.info("Using temporary artifact location: %s", artifact_location)
166+
167+
pipeline_options = beam.options.pipeline_options.PipelineOptions(
168+
pipeline_args)
169+
pipeline_options.view_as(
170+
beam.options.pipeline_options.SetupOptions
171+
).save_main_session = save_main_session
172+
173+
pipeline = test_pipeline or beam.Pipeline(options=pipeline_options)
174+
175+
# Use synthetic data or read from file
176+
if known_args.input_file:
177+
# Read and parse input data from file
178+
if known_args.input_format == 'jsonl':
179+
parse_input_fn = parse_json_line
180+
else:
181+
if len(categorical_columns) > 1:
182+
logging.warning(
183+
'Input format is "text" but multiple categorical columns are '
184+
'specified. Only the first column "%s" will be used for parsing.',
185+
categorical_columns[0])
186+
parse_input_fn = lambda line: parse_text_line(line, categorical_columns)
187+
raw_data = (
188+
pipeline
189+
| 'ReadFromJSONL' >> beam.io.ReadFromText(known_args.input_file)
190+
| 'ParseInput' >> beam.Map(parse_input_fn))
191+
else:
192+
# Generate synthetic data
193+
num_records = known_args.num_records or 100000
194+
logging.info("Generating %d synthetic records", num_records)
195+
196+
raw_data = (
197+
pipeline
198+
| 'GenerateSyntheticIndexes' >> beam.Create(range(num_records))
199+
| 'BuildSyntheticRecord' >> beam.Map(
200+
lambda idx: generate_synthetic_record(idx, categorical_columns)))
201+
202+
# Build MLTransform with ComputeAndApplyVocabulary
203+
ml_transform = MLTransform(
204+
write_artifact_location=artifact_location,
205+
).with_transform(
206+
ComputeAndApplyVocabulary(
207+
columns=categorical_columns, vocab_filename='vocab_onehot'))
208+
209+
# Apply MLTransform
210+
transformed_data = (
211+
raw_data
212+
| 'ValidateAndFilterColumns' >> beam.Filter(
213+
lambda element: all(col in element for col in categorical_columns))
214+
| 'MLTransform' >> ml_transform
215+
| 'FormatOutput' >> beam.Map(format_json_output))
216+
217+
# Write output
218+
_ = (
219+
transformed_data
220+
| 'WriteToJSONL' >> beam.io.WriteToText(
221+
known_args.output_file, file_name_suffix='.jsonl'))
222+
223+
result = pipeline.run()
224+
return result
225+
226+
227+
def parse_known_args(argv):
228+
"""Parse command-line arguments."""
229+
parser = argparse.ArgumentParser(
230+
description='Categorical encoding pipeline using MLTransform')
231+
232+
parser.add_argument(
233+
'--input_file',
234+
help='Input JSONL file path (local or GCS). '
235+
'If not provided, synthetic data will be generated.')
236+
parser.add_argument(
237+
'--input_format',
238+
choices=['jsonl', 'text'],
239+
default='jsonl',
240+
help='Input file format for --input_file. Use jsonl for JSON lines '
241+
'or text for plain text lines (default: jsonl).')
242+
parser.add_argument(
243+
'--output_file',
244+
required=True,
245+
help='Output file prefix for encoded results (JSONL format)')
246+
parser.add_argument(
247+
'--artifact_location',
248+
help='GCS or local path to store MLTransform artifacts '
249+
'(vocabulary files). If not provided, a temp location is used.')
250+
parser.add_argument(
251+
'--categorical_columns',
252+
required=True,
253+
help='Comma-separated list of categorical column names to encode')
254+
parser.add_argument(
255+
'--num_records',
256+
type=int,
257+
default=100000,
258+
help='Number of synthetic records to generate if --input_file is not '
259+
'provided (default: 100000)')
260+
261+
return parser.parse_known_args(argv)
262+
263+
264+
if __name__ == '__main__':
265+
logging.getLogger().setLevel(logging.INFO)
266+
run()

0 commit comments

Comments
 (0)