Skip to content

Commit 3415378

Browse files
authored
[YAML] A Streaming Inference Pipeline - YouTube Comments Sentiment Analysis (#35375)
* Add YAML streaming sentiment analysis pipeline * WIP YAML example for streaming sentiment analysis pipeline * Clean up * Clean up * Clean up * Add comments and update README.md * Fix rebase * Fix test and lint * Fix test * Fix CI/CD * Address comments * Address comments and fix CI/CD * Fix CI/CD
1 parent a742b90 commit 3415378

5 files changed

Lines changed: 501 additions & 9 deletions

File tree

sdks/python/apache_beam/yaml/examples/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,13 @@ gcloud dataflow yaml run $JOB_NAME \
231231

232232
### ML
233233

234-
These examples leverage the built-in `Enrichment` transform for performing
235-
ML enrichments.
234+
Examples that include the built-in `Enrichment` transform for performing
235+
ML enrichments:
236+
- [bigquery_enrichment.yaml](transforms/ml/enrichment/bigquery_enrichment.yaml)
237+
- [spanner_enrichment.yaml](transforms/ml/enrichment/spanner_enrichment.yaml)
238+
239+
Examples that include the `RunInference` transform for ML inference:
240+
- [streaming_sentiment_analysis.yaml](transforms/ml/inference/streaming_sentiment_analysis.yaml)
236241

237242
More information can be found about aggregation transforms
238243
[here](https://beam.apache.org/documentation/sdks/yaml-combine/).

sdks/python/apache_beam/yaml/examples/testing/examples_test.py

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@
3737
import apache_beam as beam
3838
from apache_beam import PCollection
3939
from apache_beam.examples.snippets.util import assert_matches_stdout
40+
from apache_beam.ml.inference.base import PredictionResult
4041
from apache_beam.options.pipeline_options import PipelineOptions
4142
from apache_beam.testing.test_pipeline import TestPipeline
43+
from apache_beam.typehints.row_type import RowTypeConstraint
4244
from apache_beam.utils import subprocess_server
4345
from apache_beam.yaml import yaml_provider
4446
from apache_beam.yaml import yaml_transform
@@ -120,14 +122,31 @@ def test_kafka_read(
120122
bootstrap_servers,
121123
auto_offset_reset_config,
122124
consumer_config):
125+
"""
126+
This PTransform simulates the behavior of the ReadFromKafka transform
127+
with the RAW format by simply using some fixed sample text data and
128+
encode it to raw bytes.
129+
130+
Args:
131+
pcoll: The input PCollection.
132+
format: The format of the Kafka messages (e.g., 'RAW').
133+
topic: The name of Kafka topic to read from.
134+
bootstrap_servers: A list of Kafka bootstrap servers to connect to.
135+
auto_offset_reset_config: A configuration for the auto offset reset
136+
consumer_config: A dictionary containing additional consumer configurations
137+
138+
Returns:
139+
A PCollection containing the sample text data in bytes.
140+
"""
141+
123142
return (
124143
pcoll | beam.Create(input_data.text_data().split('\n'))
125144
| beam.Map(lambda element: beam.Row(payload=element.encode('utf-8'))))
126145

127146

128147
@beam.ptransform.ptransform_fn
129148
def test_pubsub_read(
130-
pbegin,
149+
pcoll,
131150
topic: Optional[str] = None,
132151
subscription: Optional[str] = None,
133152
format: Optional[str] = None,
@@ -140,15 +159,58 @@ def test_pubsub_read(
140159
pubsub_messages = input_data.pubsub_messages_data()
141160

142161
return (
143-
pbegin
162+
pcoll
144163
| beam.Create([json.loads(msg.data) for msg in pubsub_messages])
145164
| beam.Map(lambda element: beam.Row(**element)))
146165

147166

167+
@beam.ptransform.ptransform_fn
168+
def test_run_inference(pcoll, inference_tag, model_handler):
169+
"""
170+
This PTransform simulates the behavior of the RunInference transform.
171+
172+
Args:
173+
pcoll: The input PCollection.
174+
inference_tag: The tag to use for the returned inference.
175+
model_handler: A configuration for the respective ML model handler
176+
177+
Returns:
178+
A PCollection containing the enriched data.
179+
"""
180+
def _fn(row):
181+
input = row._asdict()
182+
183+
row = {
184+
inference_tag: PredictionResult(
185+
input['comment_text'],
186+
[{
187+
'label': 'POSITIVE'
188+
if 'happy' in input['comment_text'] else 'NEGATIVE',
189+
'score': 0.95
190+
}]),
191+
**input
192+
}
193+
194+
return beam.Row(**row)
195+
196+
user_type = RowTypeConstraint.from_user_type(pcoll.element_type.user_type)
197+
user_schema_fields = [(name, type(typ) if not isinstance(typ, type) else typ)
198+
for (name,
199+
typ) in user_type._fields] if user_type else []
200+
inference_output_type = RowTypeConstraint.from_fields([
201+
('example', Any), ('inference', Any), ('model_id', Optional[str])
202+
])
203+
schema = RowTypeConstraint.from_fields(
204+
user_schema_fields + [(str(inference_tag), inference_output_type)])
205+
206+
return pcoll | beam.Map(_fn).with_output_types(schema)
207+
208+
148209
TEST_PROVIDERS = {
149210
'TestEnrichment': test_enrichment,
150211
'TestReadFromKafka': test_kafka_read,
151-
'TestReadFromPubSub': test_pubsub_read
212+
'TestReadFromPubSub': test_pubsub_read,
213+
'TestRunInference': test_run_inference
152214
}
153215
"""
154216
Transforms not requiring inputs.
@@ -238,7 +300,12 @@ def test_yaml_example(self):
238300
actual += list(transform.outputs.values())
239301
check_output(expected)(actual)
240302

241-
if 'deps' in pipeline_spec_file:
303+
def _python_deps_involved(spec_filename):
304+
return any(
305+
substr in spec_filename
306+
for substr in ['deps', 'streaming_sentiment_analysis'])
307+
308+
if _python_deps_involved(pipeline_spec_file):
242309
test_yaml_example = pytest.mark.no_xdist(test_yaml_example)
243310
test_yaml_example = unittest.skipIf(
244311
sys.platform == 'win32', "Github virtualenv permissions issues.")(
@@ -457,7 +524,9 @@ def _kafka_test_preprocessor(
457524
'test_pubsub_to_iceberg_yaml',
458525
'test_oracle_to_bigquery_yaml',
459526
'test_mysql_to_bigquery_yaml',
460-
'test_spanner_to_bigquery_yaml'
527+
'test_spanner_to_bigquery_yaml',
528+
'test_streaming_sentiment_analysis_yaml',
529+
'test_enrich_spanner_with_bigquery_yaml'
461530
])
462531
def _io_write_test_preprocessor(
463532
test_spec: dict, expected: List[str], env: TestEnvironment):
@@ -782,9 +851,68 @@ def _db_io_read_test_processor(
782851
return test_spec
783852

784853

854+
@YamlExamplesTestSuite.register_test_preprocessor(
855+
'test_streaming_sentiment_analysis_yaml')
856+
def _streaming_sentiment_analysis_test_preprocessor(
857+
test_spec: dict, expected: List[str], env: TestEnvironment):
858+
"""
859+
Preprocessor for tests that involve the streaming sentiment analysis example.
860+
861+
This preprocessor replaces several IO transforms and the RunInference
862+
transform.
863+
This allows the test to verify the pipeline's correctness without relying on
864+
external data sources and the model hosted on VertexAI.
865+
866+
Args:
867+
test_spec: The dictionary representation of the YAML pipeline specification.
868+
expected: A list of strings representing the expected output of the
869+
pipeline.
870+
env: The TestEnvironment object providing utilities for creating temporary
871+
files.
872+
873+
Returns:
874+
The modified test_spec dictionary with ... transforms replaced.
875+
"""
876+
if pipeline := test_spec.get('pipeline', None):
877+
for transform in pipeline.get('transforms', []):
878+
if transform.get('type', '') == 'PyTransform' and transform.get(
879+
'name', '') == 'ReadFromGCS':
880+
transform['windowing'] = {'type': 'fixed', 'size': '30s'}
881+
882+
file_name = 'youtube-comments.csv'
883+
local_path = env.input_file(file_name, INPUT_FILES[file_name])
884+
transform['config']['kwargs']['file_pattern'] = local_path
885+
886+
if pipeline := test_spec.get('pipeline', None):
887+
for transform in pipeline.get('transforms', []):
888+
if transform.get('type', '') == 'ReadFromKafka':
889+
config = transform['config']
890+
transform['type'] = 'ReadFromCsv'
891+
transform['config'] = {
892+
k: v
893+
for k, v in config.items() if k.startswith('__')
894+
}
895+
transform['config']['path'] = ""
896+
897+
file_name = 'youtube-comments.csv'
898+
test_spec = replace_recursive(
899+
test_spec,
900+
transform['type'],
901+
'path',
902+
env.input_file(file_name, INPUT_FILES[file_name]))
903+
904+
if pipeline := test_spec.get('pipeline', None):
905+
for transform in pipeline.get('transforms', []):
906+
if transform.get('type', '') == 'RunInference':
907+
transform['type'] = 'TestRunInference'
908+
909+
return test_spec
910+
911+
785912
INPUT_FILES = {
786913
'products.csv': input_data.products_csv(),
787-
'kinglear.txt': input_data.text_data()
914+
'kinglear.txt': input_data.text_data(),
915+
'youtube-comments.csv': input_data.youtube_comments_csv()
788916
}
789917

790918
INPUT_TABLES = {
@@ -819,7 +947,7 @@ def _db_io_read_test_processor(
819947
'../transforms/io/*.yaml')).run()
820948
MLTest = YamlExamplesTestSuite(
821949
'MLExamplesTest', os.path.join(YAML_DOCS_DIR,
822-
'../transforms/ml/*.yaml')).run()
950+
'../transforms/ml/**/*.yaml')).run()
823951

824952
if __name__ == '__main__':
825953
logging.getLogger().setLevel(logging.INFO)

sdks/python/apache_beam/yaml/examples/testing/input_data.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ def products_csv():
5454
])
5555

5656

57+
def youtube_comments_csv():
58+
return '\n'.join([
59+
'video_id,comment_text,likes,replies',
60+
'XpVt6Z1Gjjo,I AM HAPPY,1,1',
61+
'XpVt6Z1Gjjo,I AM SAD,1,1',
62+
'XpVt6Z1Gjjo,§ÁĐ,1,1'
63+
])
64+
65+
5766
def spanner_orders_data():
5867
return [{
5968
'order_id': 1,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
## Streaming Sentiment Analysis
21+
22+
The example leverages the `RunInference` transform with Vertex AI
23+
model handler [VertexAIModelHandlerJSON](
24+
https://beam.apache.org/releases/pydoc/current/apache_beam.yaml.yaml_ml#apache_beam.yaml.yaml_ml.VertexAIModelHandlerJSONProvider),
25+
in addition to Kafka IO to demonstrate an end-to-end example of a
26+
streaming sentiment analysis pipeline. The dataset to perform
27+
sentiment analysis on is the YouTube video comments and can be found
28+
on Kaggle [here](
29+
https://www.kaggle.com/datasets/datasnaek/youtube?select=UScomments.csv).
30+
31+
Download the dataset and copy over to a GCS bucket:
32+
```sh
33+
gcloud storage cp /path/to/UScomments.csv gs://YOUR_BUCKET/UScomments.csv
34+
```
35+
36+
For setting up Kafka, an option is to use [Click to Deploy](
37+
https://console.cloud.google.com/marketplace/details/click-to-deploy-images/kafka?)
38+
to quickly launch a Kafka cluster on GCE. See [here](
39+
../../../README.md#kafka) for more context around using Kafka
40+
with Dataflow.
41+
42+
A hosted model on Vertex AI is needed before being able to use
43+
the Vertex AI model handler. One of the current state-of-the-art
44+
NLP models is HuggingFace's DistilBERT, a distilled version of
45+
BERT model and is faster at inference. To deploy DistilBERT on
46+
Vertex AI, run this [notebook](
47+
https://github.com/GoogleCloudPlatform/vertex-ai-samples/blob/main/notebooks/community/model_garden/model_garden_huggingface_pytorch_inference_deployment.ipynb) in Colab Enterprise.
48+
49+
BigQuery is the pipeline's sink for the inference result output.
50+
A BigQuery dataset needs to exist first before the pipeline can
51+
create/write to a table. Run the following command to create
52+
a BigQuery dataset:
53+
54+
```sh
55+
bq --location=us-central1 mk \
56+
--dataset DATASET_ID
57+
```
58+
See also [here](
59+
https://cloud.google.com/bigquery/docs/datasets) for more details on
60+
how to create BigQuery datasets
61+
62+
The pipeline first reads the YouTube comments .csv dataset from
63+
GCS bucket and performs some clean-up before writing it to a Kafka
64+
topic. The pipeline then reads from that Kafka topic and applies
65+
various transformation logic before `RunInference` transform performs
66+
remote inference with the Vertex AI model handler and DistilBERT
67+
deployed to a Vertex AI endpoint. The inference result is then
68+
parsed and written to a BigQuery table.
69+
70+
Run the pipeline (replace with appropriate variables in the command
71+
below):
72+
73+
```sh
74+
export PROJECT="$(gcloud config get-value project)"
75+
export TEMP_LOCATION="gs://YOUR-BUCKET/tmp"
76+
export REGION="us-central1"
77+
export JOB_NAME="streaming-sentiment-analysis-`date +%Y%m%d-%H%M%S`"
78+
export NUM_WORKERS="3"
79+
80+
python -m apache_beam.yaml.main \
81+
--yaml_pipeline_file transforms/ml/sentiment_analysis/streaming_sentiment_analysis.yaml \
82+
--runner DataflowRunner \
83+
--temp_location $TEMP_LOCATION \
84+
--project $PROJECT \
85+
--region $REGION \
86+
--num_workers $NUM_WORKERS \
87+
--job_name $JOB_NAME \
88+
--jinja_variables '{ "GCS_PATH": "gs://YOUR-BUCKET/USComments.csv",
89+
"BOOTSTRAP_SERVERS": "BOOTSTRAP_IP_ADD:9092",
90+
"TOPIC": "YOUR_TOPIC", "USERNAME": "KAFKA_USERNAME", "PASSWORD": "KAFKA_PASSWORD",
91+
"ENDPOINT": "ENDPOINT_ID", "PROJECT": "PROJECT_ID", "LOCATION": "LOCATION",
92+
"DATASET": "DATASET_ID", "TABLE": "TABLE_ID" }'
93+
```

0 commit comments

Comments
 (0)