Distributed Compute¶
Engine choice depends on the consumer:
- Spark (
mapInArrow,createDataFrame) and Ray (map_batches):engine="arrow[spark]". Both reject Polars'sstring_viewandduration[ms];arrow[spark]pre-casts them tostringandint64. - Dask (
.to_pandas()in the worker) or bare pyarrow:engine="arrow". Pandas absorbs Polars-style Arrow types directly.
Both engines return identical data with different Arrow dtypes. Both accept raw bytes, bytearray, memoryview, or io.BytesIO for the tracking and metadata inputs. Neither accepts file paths or URLs. Neither imports kloppy on executors, so workers stay small.
Spark¶
The example is provider-agnostic. {provider} stands for any single-file provider. Multi-file providers (hawkeye, signality) do not fit this shape; see Multi-file providers.
One row per match on the driver, carrying match_id and the file URIs as strings. The worker fetches bytes inside the UDF, parses one match, and yields Arrow batches back.
from pyspark.sql import SparkSession
from fastforward import {provider} # e.g. skillcorner, secondspectrum, tracab
spark = SparkSession.builder.getOrCreate()
# Define every load kwarg once. Schema declaration and worker load unpack the
# same dict; the two must agree.
LOAD_KWARGS = dict(
engine="arrow[spark]",
layout="long",
include_game_id=True,
)
# 1. Build a Spark DataFrame with one row per match.
# Required columns:
# match_id: string
# raw_data_uri: string (e.g. "s3://bucket/matches/123/tracking.jsonl")
# meta_data_uri: string (e.g. "s3://bucket/matches/123/match.json")
# Column names track the provider's `load_tracking` argument names.
matches_df = ... # your source of matches
# 2. Declare the output schema upfront. Same kwargs as the per-worker load.
out_schema = {provider}.schemas(**LOAD_KWARGS).tracking_spark
# 3. Per-worker UDF. One task per match.
def parse_match_udf(iterator):
for batch in iterator:
raw_uris = batch.column("raw_data_uri").to_pylist()
meta_uris = batch.column("meta_data_uri").to_pylist()
for r_uri, m_uri in zip(raw_uris, meta_uris):
raw = read_bytes(r_uri) # bring your own S3 / GCS / Azure client
meta = read_bytes(m_uri)
dataset = {provider}.load_tracking(raw, meta, **LOAD_KWARGS)
for record_batch in dataset.tracking.to_batches():
yield record_batch
# 4. One task per match. Without this, upstream partitioning is uncorrelated
# with per-match cost and produces long-tail skew on matches with more frames.
matches_df = matches_df.repartition(matches_df.count(), "match_id")
# 5. Distribute one match per task.
tracking_df = matches_df.mapInArrow(parse_match_udf, schema=out_schema)
# 6. Partition the write by game_id so re-runs replace matches idempotently.
# Iceberg or Delta MERGE work equivalently.
tracking_df.write.partitionBy("game_id").parquet("s3a://my-bucket/tracking/")
mapInArrow requires PySpark 3.3 or newer. read_bytes(uri) is a placeholder for the object-store client of choice.
Ray map_batches¶
Same URI shape. Ray's pyarrow path also rejects string_view, so engine="arrow[spark]" is required. The batch is a dict of columns rather than a RecordBatch iterator, and Ray infers the output schema from the returned table.
import ray
import pyarrow as pa
from fastforward import {provider}
matches_ds = ... # ray.data.Dataset with match_id, raw_data_uri, meta_data_uri
def parse_batch(batch):
out = []
for r_uri, m_uri in zip(batch["raw_data_uri"], batch["meta_data_uri"]):
raw = read_bytes(r_uri)
meta = read_bytes(m_uri)
ds = {provider}.load_tracking(raw, meta, engine="arrow[spark]")
out.append(ds.tracking)
return pa.concat_tables(out)
tracking_ds = matches_ds.map_batches(parse_batch, batch_format="pyarrow")
Dask map_partitions¶
Same URI shape. Dask goes through pandas via .to_pandas(), so engine="arrow" fits (no Spark-compat pre-casts needed). map_partitions needs a meta pandas frame describing the output columns.
import pandas as pd
import dask.dataframe as dd
from fastforward import {provider}
matches_ddf = ... # dask DataFrame with match_id, raw_data_uri, meta_data_uri
def parse_partition(df):
out = []
for _, row in df.iterrows():
raw = read_bytes(row["raw_data_uri"])
meta = read_bytes(row["meta_data_uri"])
ds = {provider}.load_tracking(raw, meta, engine="arrow")
out.append(ds.tracking.to_pandas())
return pd.concat(out, ignore_index=True)
tracking_meta = (
{provider}.schemas(engine="arrow").tracking.empty_table().to_pandas()
)
tracking_ddf = matches_ddf.map_partitions(parse_partition, meta=tracking_meta)
Per-provider input shape¶
Providers take different input names; two (hawkeye, signality) split a match across multiple files.
| Provider | Required inputs | Multi-file | Extra kwargs |
|---|---|---|---|
cdf |
raw_data, meta_data |
no | |
gradientsports |
raw_data, meta_data, roster_data |
no | |
hawkeye |
ball_data, player_data, meta_data |
yes (per period + minute) | period=, minute=, see Multi-file providers |
optavision |
raw_data, meta_data |
no | |
respovision |
raw_data |
no | filename= (parses team names from the filename) |
scisports |
raw_data, meta_data |
no | |
secondspectrum |
raw_data, meta_data |
no | |
signality |
meta_data, raw_data_feeds, venue_information |
yes (per period) | period=, see Multi-file providers |
skillcorner |
raw_data, meta_data |
no | |
sportec |
raw_data, meta_data |
no | |
statsperform |
ma25_data, ma1_data |
no | pitch_length=, pitch_width= (no defaults in feed) |
tracab |
raw_data, meta_data |
no |
For single-file providers, name the URI columns after the provider's argument names (e.g. raw_data_uri, meta_data_uri for SkillCorner; ma25_data_uri, ma1_data_uri for StatsPerform).
Multi-file providers¶
hawkeye and signality split a match across multiple files. The upstream DataFrame carries one row per slice, not per match, and the worker forwards the slice-identifying kwargs from the row into load_tracking. Otherwise the worker body is the same shape as the Spark example above: fetch bytes, call provider.load_tracking(...), yield Arrow batches.
hawkeye: one row per(match_id, period, minute)slice withball_data_uri,player_data_uri,meta_data_uri. Worker forwardsperiod=,minute=, andinclude_game_id=match_id.signality: one row per(match_id, period)withmeta_data_uri,raw_data_feeds_uri,venue_information_uri. Worker forwardsperiod=andinclude_game_id=match_id.
Schema helpers¶
provider.schemas(**LOAD_KWARGS) returns a Schemas namespace with 10 lazy properties (5 tables x {Arrow, PySpark}):
| Table | Arrow (pyarrow.Schema) |
PySpark (StructType) |
|---|---|---|
tracking |
s.tracking |
s.tracking_spark |
metadata |
s.metadata |
s.metadata_spark |
teams |
s.teams |
s.teams_spark |
players |
s.players |
s.players_spark |
periods |
s.periods |
s.periods_spark |
Two entry points:
provider.schemas(**LOAD_KWARGS)when the schema is needed before any data is loaded, for example themapInArrowoutput schema. Accepts the same kwargs asload_tracking.dataset.schemason a loaded dataset, with the load's kwargs already bound.
engine= on schemas() mirrors load_tracking: polars / arrow produce Polars-style Arrow (string_view, duration[ms]); pyspark / arrow[spark] produce Spark-compat Arrow (string, int64). *_spark properties are always Spark-compatible regardless of engine.
layout="wide" has per-game column names, so s.tracking and s.tracking_spark raise NotImplementedError. The other four schemas still work.
Produced schema¶
See TrackingDataset - Per-engine schema.
Common mistakes¶
engine="arrow"on Ray. Ray's pyarrow path does not supportstring_view;arrow[spark]is required.layout="wide"under the arrow engines.mapInArrowneeds a static output schema, and wide has per-game column names (player IDs become columns).load_trackingandschemas().trackingboth raiseNotImplementedError.longandlong_ballare supported.- Hand-written
StructType. Callprovider.schemas(...).tracking_spark. Hand-written schemas drift from the parser and misdeclareframe_idasIntegerType(parser emitsLongType, since PySpark's Arrow path cannot represent unsigned ints) ortimestampasfloatseconds (parser emitsint64milliseconds). - Per-match rows for
hawkeyeorsignality. Both are multi-file: build the upstream frame with one row per slice and forwardperiod=(andminute=for HawkEye) from the row. See Multi-file providers.
Databricks Runtime compatibility¶
engine="arrow" and engine="arrow[spark]" require pyarrow >= 14. Databricks Runtime 15+ ships pyarrow 14; DBR 14 LTS and older ship pyarrow 8-12. On DBR <= 14, install a newer pyarrow on the cluster: