Skip to content

Commit 1aa3592

Browse files
authored
[YAML] A Streaming Inference Pipeline - Taxi Fare Estimation (#35568)
* Initial * YAML streaming taxi fare prediction pipeline and model training/deploy Add streaming YAML pipeline for the taxi fare regression problem, as well as notebook for model training and deployment to Vertex AI * Touch up with comments and doc Complete the model training and deployment notebook. Complete README.md * Fix up * Unit test the pipeline logic * Touch up * Fix CI/CD * Address review
1 parent f2f0882 commit 1aa3592

5 files changed

Lines changed: 1429 additions & 29 deletions

File tree

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

Lines changed: 192 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,39 @@ def _fn(row):
117117
@beam.ptransform.ptransform_fn
118118
def test_kafka_read(
119119
pcoll,
120-
format,
121-
topic,
122-
bootstrap_servers,
123-
auto_offset_reset_config,
124-
consumer_config):
120+
topic: Optional[str] = None,
121+
format: Optional[str] = None,
122+
schema: Optional[Any] = None,
123+
bootstrap_servers: Optional[str] = None,
124+
auto_offset_reset_config: Optional[str] = None,
125+
consumer_config: Optional[Any] = None):
125126
"""
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.
127+
Mocks the ReadFromKafka transform for testing purposes.
128+
129+
This PTransform simulates the behavior of the ReadFromKafka transform by
130+
reading from predefined in-memory data based on the Kafka topic argument.
129131
130132
Args:
131133
pcoll: The input PCollection.
132-
format: The format of the Kafka messages (e.g., 'RAW').
133134
topic: The name of Kafka topic to read from.
135+
format: The format of the Kafka messages (e.g., 'RAW').
136+
schema: The schema of the Kafka messages.
134137
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
138+
auto_offset_reset_config: A configuration for the auto offset reset.
139+
consumer_config: A map for additional consumer configuration parameters.
137140
138141
Returns:
139-
A PCollection containing the sample text data in bytes.
142+
A PCollection containing the sample data.
140143
"""
141144

142-
return (
143-
pcoll | beam.Create(input_data.text_data().split('\n'))
144-
| beam.Map(lambda element: beam.Row(payload=element.encode('utf-8'))))
145+
if topic == 'test-topic':
146+
kafka_byte_messages = KAFKA_TOPICS['test-topic']
147+
return (
148+
pcoll
149+
| beam.Create([msg.decode('utf-8') for msg in kafka_byte_messages])
150+
| beam.Map(lambda element: beam.Row(payload=element.encode('utf-8'))))
151+
152+
return None
145153

146154

147155
@beam.ptransform.ptransform_fn
@@ -155,17 +163,70 @@ def test_pubsub_read(
155163
attributes_map: Optional[str] = None,
156164
id_attribute: Optional[str] = None,
157165
timestamp_attribute: Optional[str] = None):
166+
"""
167+
Mocks the ReadFromPubSub transform for testing purposes.
168+
169+
This PTransform simulates the behavior of the ReadFromPubSub transform by
170+
reading from predefined in-memory data based on the Pub/Sub topic argument.
171+
Args:
172+
pcoll: The input PCollection.
173+
topic: The name of Pub/Sub topic to read from.
174+
subscription: The name of Pub/Sub subscription to read from.
175+
format: The format of the Pub/Sub messages (e.g., 'JSON').
176+
schema: The schema of the Pub/Sub messages.
177+
attributes: A list of attributes to include in the output.
178+
attributes_map: A string representing a mapping of attributes.
179+
id_attribute: The attribute to use as the ID for the message.
180+
timestamp_attribute: The attribute to use as the timestamp for the message.
181+
182+
Returns:
183+
A PCollection containing the sample data.
184+
"""
185+
186+
if topic == 'test-topic':
187+
pubsub_messages = PUBSUB_TOPICS['test-topic']
188+
return (
189+
pcoll
190+
| beam.Create([json.loads(msg.data) for msg in pubsub_messages])
191+
| beam.Map(lambda element: beam.Row(**element)))
192+
elif topic == 'taxi-ride-topic':
193+
pubsub_messages = PUBSUB_TOPICS['taxi-ride-topic']
194+
schema = input_data.TaxiRideEventSchema
195+
return (
196+
pcoll
197+
| beam.Create([json.loads(msg.data) for msg in pubsub_messages])
198+
|
199+
beam.Map(lambda element: beam.Row(**element)).with_output_types(schema))
200+
201+
return None
202+
203+
204+
@beam.ptransform.ptransform_fn
205+
def test_run_inference_taxi_fare(pcoll, inference_tag, model_handler):
206+
"""
207+
This PTransform simulates the behavior of the RunInference transform.
208+
209+
Args:
210+
pcoll: The input PCollection.
211+
inference_tag: The tag to use for the returned inference.
212+
model_handler: A configuration for the respective ML model handler
213+
214+
Returns:
215+
A PCollection containing the enriched data.
216+
"""
217+
def _fn(row):
218+
input = row._asdict()
158219

159-
pubsub_messages = input_data.pubsub_messages_data()
220+
row = {inference_tag: PredictionResult(input, 10.0), **input}
160221

161-
return (
162-
pcoll
163-
| beam.Create([json.loads(msg.data) for msg in pubsub_messages])
164-
| beam.Map(lambda element: beam.Row(**element)))
222+
return beam.Row(**row)
223+
224+
schema = _format_predicition_result_ouput(pcoll, inference_tag)
225+
return pcoll | beam.Map(_fn).with_output_types(schema)
165226

166227

167228
@beam.ptransform.ptransform_fn
168-
def test_run_inference(pcoll, inference_tag, model_handler):
229+
def test_run_inference_youtube_comments(pcoll, inference_tag, model_handler):
169230
"""
170231
This PTransform simulates the behavior of the RunInference transform.
171232
@@ -193,24 +254,28 @@ def _fn(row):
193254

194255
return beam.Row(**row)
195256

257+
schema = _format_predicition_result_ouput(pcoll, inference_tag)
258+
return pcoll | beam.Map(_fn).with_output_types(schema)
259+
260+
261+
def _format_predicition_result_ouput(pcoll, inference_tag):
196262
user_type = RowTypeConstraint.from_user_type(pcoll.element_type.user_type)
197263
user_schema_fields = [(name, type(typ) if not isinstance(typ, type) else typ)
198264
for (name,
199265
typ) in user_type._fields] if user_type else []
200266
inference_output_type = RowTypeConstraint.from_fields([
201267
('example', Any), ('inference', Any), ('model_id', Optional[str])
202268
])
203-
schema = RowTypeConstraint.from_fields(
269+
return RowTypeConstraint.from_fields(
204270
user_schema_fields + [(str(inference_tag), inference_output_type)])
205271

206-
return pcoll | beam.Map(_fn).with_output_types(schema)
207-
208272

209273
TEST_PROVIDERS = {
210274
'TestEnrichment': test_enrichment,
211275
'TestReadFromKafka': test_kafka_read,
212276
'TestReadFromPubSub': test_pubsub_read,
213-
'TestRunInference': test_run_inference
277+
'TestRunInferenceYouTubeComments': test_run_inference_youtube_comments,
278+
'TestRunInferenceTaxiFare': test_run_inference_taxi_fare,
214279
}
215280
"""
216281
Transforms not requiring inputs.
@@ -305,6 +370,11 @@ def _python_deps_involved(spec_filename):
305370
substr in spec_filename
306371
for substr in ['deps', 'streaming_sentiment_analysis'])
307372

373+
def _java_deps_involved(spec_filename):
374+
return any(
375+
substr in spec_filename
376+
for substr in ['java_deps', 'streaming_taxifare_prediction'])
377+
308378
if _python_deps_involved(pipeline_spec_file):
309379
test_yaml_example = pytest.mark.no_xdist(test_yaml_example)
310380
test_yaml_example = unittest.skipIf(
@@ -319,7 +389,7 @@ def _python_deps_involved(spec_filename):
319389
'Github actions environment issue.')(
320390
test_yaml_example)
321391

322-
if 'java_deps' in pipeline_spec_file:
392+
if _java_deps_involved(pipeline_spec_file):
323393
test_yaml_example = pytest.mark.xlang_sql_expansion_service(
324394
test_yaml_example)
325395
test_yaml_example = unittest.skipIf(
@@ -500,6 +570,7 @@ def _kafka_test_preprocessor(
500570
for transform in pipeline.get('transforms', []):
501571
if transform.get('type', '') == 'ReadFromKafka':
502572
transform['type'] = 'TestReadFromKafka'
573+
transform['config']['topic'] = 'test-topic'
503574

504575
return test_spec
505576

@@ -525,8 +596,7 @@ def _kafka_test_preprocessor(
525596
'test_oracle_to_bigquery_yaml',
526597
'test_mysql_to_bigquery_yaml',
527598
'test_spanner_to_bigquery_yaml',
528-
'test_streaming_sentiment_analysis_yaml',
529-
'test_enrich_spanner_with_bigquery_yaml'
599+
'test_streaming_sentiment_analysis_yaml'
530600
])
531601
def _io_write_test_preprocessor(
532602
test_spec: dict, expected: List[str], env: TestEnvironment):
@@ -740,6 +810,7 @@ def _pubsub_io_read_test_preprocessor(
740810
for transform in pipeline.get('transforms', []):
741811
if transform.get('type', '') == 'ReadFromPubSub':
742812
transform['type'] = 'TestReadFromPubSub'
813+
transform['config']['topic'] = 'test-topic'
743814

744815
return test_spec
745816

@@ -904,7 +975,90 @@ def _streaming_sentiment_analysis_test_preprocessor(
904975
if pipeline := test_spec.get('pipeline', None):
905976
for transform in pipeline.get('transforms', []):
906977
if transform.get('type', '') == 'RunInference':
907-
transform['type'] = 'TestRunInference'
978+
transform['type'] = 'TestRunInferenceYouTubeComments'
979+
980+
return test_spec
981+
982+
983+
@YamlExamplesTestSuite.register_test_preprocessor(
984+
'test_streaming_taxifare_prediction_yaml')
985+
def _streaming_taxifare_prediction_test_preprocessor(
986+
test_spec: dict, expected: List[str], env: TestEnvironment):
987+
"""
988+
Preprocessor for tests that involve the streaming taxi fare prediction
989+
example.
990+
991+
This preprocessor replaces several IO transforms and the RunInference
992+
transform. This allows the test to verify the pipeline's correctness
993+
without relying on external data sources and the model hosted on VertexAI.
994+
It also turns this non-linear pipeline into a linear pipeline by replacing
995+
the ReadFromKafka and WriteToKafka transforms with MapToFields and linking
996+
the two disconnected pipeline components together. The pipeline logic,
997+
however, remains the same and is still being tested accordingly.
998+
999+
Args:
1000+
test_spec: The dictionary representation of the YAML pipeline specification.
1001+
expected: A list of strings representing the expected output of the
1002+
pipeline.
1003+
env: The TestEnvironment object providing utilities for creating temporary
1004+
files.
1005+
1006+
Returns:
1007+
The modified test_spec dictionary with several involved IO transforms and
1008+
the RunInference transform replaced.
1009+
"""
1010+
1011+
if pipeline := test_spec.get('pipeline', None):
1012+
for transform in pipeline.get('transforms', []):
1013+
if transform.get('type', '') == 'ReadFromPubSub':
1014+
transform['type'] = 'TestReadFromPubSub'
1015+
transform['config']['topic'] = 'taxi-ride-topic'
1016+
1017+
elif transform.get('type', '') == 'WriteToKafka':
1018+
transform['type'] = 'MapToFields'
1019+
transform['config'] = {
1020+
k: v
1021+
for (k, v) in transform.get('config', {}).items()
1022+
if k.startswith('__')
1023+
}
1024+
transform['config']['fields'] = {
1025+
'ride_id': 'ride_id',
1026+
'pickup_longitude': 'pickup_longitude',
1027+
'pickup_latitude': 'pickup_latitude',
1028+
'pickup_datetime': 'pickup_datetime',
1029+
'dropoff_longitude': 'dropoff_longitude',
1030+
'dropoff_latitude': 'dropoff_latitude',
1031+
'passenger_count': 'passenger_count',
1032+
}
1033+
1034+
elif transform.get('type', '') == 'ReadFromKafka':
1035+
transform['type'] = 'MapToFields'
1036+
transform['config'] = {
1037+
k: v
1038+
for (k, v) in transform.get('config', {}).items()
1039+
if k.startswith('__')
1040+
}
1041+
transform['input'] = 'WriteKafka'
1042+
transform['config']['fields'] = {
1043+
'ride_id': 'ride_id',
1044+
'pickup_longitude': 'pickup_longitude',
1045+
'pickup_latitude': 'pickup_latitude',
1046+
'pickup_datetime': 'pickup_datetime',
1047+
'dropoff_longitude': 'dropoff_longitude',
1048+
'dropoff_latitude': 'dropoff_latitude',
1049+
'passenger_count': 'passenger_count',
1050+
}
1051+
1052+
elif transform.get('type', '') == 'WriteToBigQuery':
1053+
transform['type'] = 'LogForTesting'
1054+
transform['config'] = {
1055+
k: v
1056+
for (k, v) in transform.get('config', {}).items()
1057+
if (k.startswith('__') or k == 'error_handling')
1058+
}
1059+
1060+
elif transform.get('type', '') == 'RunInference':
1061+
transform['type'] = 'TestRunInferenceTaxiFare'
9081062

9091063
return test_spec
9101064

@@ -915,6 +1069,15 @@ def _streaming_sentiment_analysis_test_preprocessor(
9151069
'youtube-comments.csv': input_data.youtube_comments_csv()
9161070
}
9171071

1072+
KAFKA_TOPICS = {
1073+
'test-topic': input_data.kafka_messages_data(),
1074+
}
1075+
1076+
PUBSUB_TOPICS = {
1077+
'test-topic': input_data.pubsub_messages_data(),
1078+
'taxi-ride-topic': input_data.pubsub_taxi_ride_events_data()
1079+
}
1080+
9181081
INPUT_TABLES = {
9191082
('shipment-test', 'shipment', 'shipments'): input_data.shipments_data(),
9201083
('orders-test', 'order-database', 'orders'): input_data.

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# limitations under the License.
1717
#
1818

19+
import typing
20+
1921
from apache_beam.io.gcp.pubsub import PubsubMessage
2022

2123
# This file contains the input data to be requested by the example tests, if
@@ -186,3 +188,68 @@ def pubsub_messages_data():
186188
PubsubMessage(data=b"{\"label\": \"37c\", \"rank\": 3}", attributes={}),
187189
PubsubMessage(data=b"{\"label\": \"37d\", \"rank\": 2}", attributes={}),
188190
]
191+
192+
193+
def pubsub_taxi_ride_events_data():
194+
"""
195+
Provides a list of PubsubMessage objects for testing taxi ride events.
196+
"""
197+
return [
198+
PubsubMessage(
199+
data=b"{\"ride_id\": \"1\", \"longitude\": 11.0, \"latitude\": -11.0,"
200+
b"\"passenger_count\": 1, \"meter_reading\": 100.0, \"timestamp\": "
201+
b"\"2025-01-01T00:29:00.00000-04:00\", \"ride_status\": \"pickup\"}",
202+
attributes={}),
203+
PubsubMessage(
204+
data=b"{\"ride_id\": \"2\", \"longitude\": 22.0, \"latitude\": -22.0,"
205+
b"\"passenger_count\": 2, \"meter_reading\": 100.0, \"timestamp\": "
206+
b"\"2025-01-01T00:30:00.00000-04:00\", \"ride_status\": \"pickup\"}",
207+
attributes={}),
208+
PubsubMessage(
209+
data=b"{\"ride_id\": \"1\", \"longitude\": 13.0, \"latitude\": -13.0,"
210+
b"\"passenger_count\": 1, \"meter_reading\": 100.0, \"timestamp\": "
211+
b"\"2025-01-01T00:31:00.00000-04:00\", \"ride_status\": \"enroute\"}",
212+
attributes={}),
213+
PubsubMessage(
214+
data=b"{\"ride_id\": \"2\", \"longitude\": 24.0, \"latitude\": -24.0,"
215+
b"\"passenger_count\": 2, \"meter_reading\": 100.0, \"timestamp\": "
216+
b"\"2025-01-01T00:32:00.00000-04:00\", \"ride_status\": \"enroute\"}",
217+
attributes={}),
218+
PubsubMessage(
219+
data=b"{\"ride_id\": \"3\", \"longitude\": 33.0, \"latitude\": -33.0,"
220+
b"\"passenger_count\": 3, \"meter_reading\": 100.0, \"timestamp\": "
221+
b"\"2025-01-01T00:35:00.00000-04:00\", \"ride_status\": \"enroute\"}",
222+
attributes={}),
223+
PubsubMessage(
224+
data=b"{\"ride_id\": \"4\", \"longitude\": 44.0, \"latitude\": -44.0,"
225+
b"\"passenger_count\": 4, \"meter_reading\": 100.0, \"timestamp\": "
226+
b"\"2025-01-01T00:35:00.00000-04:00\", \"ride_status\": \"dropoff\"}",
227+
attributes={}),
228+
PubsubMessage(
229+
data=b"{\"ride_id\": \"1\", \"longitude\": 15.0, \"latitude\": -15.0,"
230+
b"\"passenger_count\": 1, \"meter_reading\": 100.0, \"timestamp\": "
231+
b"\"2025-01-01T00:33:00.00000-04:00\", \"ride_status\": \"dropoff\"}",
232+
attributes={}),
233+
PubsubMessage(
234+
data=b"{\"ride_id\": \"2\", \"longitude\": 26.0, \"latitude\": -26.0,"
235+
b"\"passenger_count\": 2, \"meter_reading\": 100.0, \"timestamp\": "
236+
b"\"2025-01-01T00:34:00.00000-04:00\", \"ride_status\": \"dropoff\"}",
237+
attributes={}),
238+
]
239+
240+
241+
def kafka_messages_data():
242+
"""
243+
Provides a list of Kafka messages for testing.
244+
"""
245+
return [data.encode('utf-8') for data in text_data().split('\n')]
246+
247+
248+
class TaxiRideEventSchema(typing.NamedTuple):
249+
ride_id: str
250+
longitude: float
251+
latitude: float
252+
passenger_count: int
253+
meter_reading: float
254+
timestamp: str
255+
ride_status: str

0 commit comments

Comments
 (0)