Turning raw streaming behavior into durable, queryable viewer attributes, with schema validation that catches bad data before anything downstream reads it.
Raw viewing and subscription events are high-volume and close to useless to query directly. Nobody asks "list every play event for viewer 41." They ask what that viewer is into, which platform holds their attention, and whether they are about to leave. This pipeline does that translation, stores the result as a graph so the derived attributes stay attached to a single canonical viewer, and validates the whole thing against a schema before it is trusted.
Implemented in RDF and OWL with SPARQL for reads and SHACL for validation, because a graph is the honest shape for this data. Every derived attribute has to point back at one viewer across three different event types that share no natural key, which is a join problem before it is a storage problem.
- Generate. A deterministic synthetic dataset of 60 viewers, 6 platforms,
30 titles, and 10 genres, plus their viewing and subscription events. Seeded
(
SEED=42), so every run reproduces exactly. - Derive. Raw
ViewingEventandSubscriptionEventtriples are aggregated per viewer into four durable attribute types, written back into the graph as new nodes.- Genre affinity, share of watch-minutes per genre, ranked, carrying its own provenance (the source event count behind each score)
- Brand affinity, the same operation per platform
- Viewing summary, total minutes, distinct titles, primary platform
- Lifecycle signal, active / at_risk / churned, from subscription history plus days since last view
- Validate. SHACL shapes run over the derived graph and report every violation with its subject, path, and constraint.
- Query. Five SPARQL reads answering the questions the attributes exist for.
pip install -r requirements.txt
python run_all.pyEach step also runs standalone (generate_sample_data.py, derive_affinities.py,
validate.py, queries.py).
ViewingEvent, SubscriptionEvent, and every derived AffinityScore resolve
back to one canonical Viewer URI. That is what makes a query like "show me
at-risk viewers joined to their top genre" a single traversal instead of a
three-way reconciliation. The graph does not fix the identity problem, it just
makes the fix legible once you have done it, and doing it is the work.
generate_sample_data.py builds the whole world from a fixed seed. Roughly 4
percent of ViewingEvents carry an invalid negative watch duration, planted
deliberately, so the validator has something real to catch. A validation layer
that reports a clean pass over clean data proves nothing.
The derivation layer excludes those rows before aggregating, so every derived attribute is clean even though the raw graph is not. That split is the point. Validation reports on the raw truth; derivation defends the outputs.
Generated 60 viewers, 6 platforms, 30 titles, 10 genres.
Total triples: 6020
Wrote data/raw_instances.ttl
Derived 342 genre-affinity scores, 279 brand-affinity scores,
60 viewing summaries, 60 lifecycle signals.
Graph grew from 6123 to 10389 triples (4266 derived).
Wrote data/full_graph.ttl
Conforms: False
Violations reported: 24
(all 24 are the seeded negative-duration ViewingEvents, correctly caught by
ViewingEventShape's sh:minInclusive constraint)
=== Q1: Top genre affinity per viewer ===
Viewer 013 | Documentary | 0.9698 | 4
Viewer 041 | Horror | 0.8453 | 10
...
=== Q3: At-risk / churned viewers, joined to top genre affinity ===
Viewer 055 | at_risk | 173 days | Drama | 0.5366
Viewer 023 | churned | 163 days | Comedy | 0.3972
...
The two triple counts are different numbers on purpose. 6020 is the instance data alone. 6123 is that data with the schema loaded on top of it, which is what the derivation step reads.
9 classes. Viewer, Platform, Title, Genre, ViewingEvent, SubscriptionEvent, AffinityScore, ViewingSummary, LifecycleSignal.
26 properties, 14 object and 12 datatype, with domains and ranges declared.
6 SHACL NodeShapes, one per validated class (Viewer, ViewingEvent, SubscriptionEvent, Title, AffinityScore, LifecycleSignal).
5 SPARQL 1.1 queries, covering aggregation, grouped subqueries, multi-hop traversal, and filtering.
What this is not. It runs in rdflib against a local graph, not a hosted triplestore, and not at production volume. It demonstrates the notation and the underlying operation on a dataset small enough to verify by hand. Treat it as a worked example, not as evidence of scale.
| File | Purpose |
|---|---|
ontology_schema.ttl |
RDFS/OWL schema, classes, properties, domains and ranges |
generate_sample_data.py |
Deterministic generator, writes data/raw_instances.ttl |
derive_affinities.py |
Event-to-attribute derivation, writes data/full_graph.ttl |
shapes.ttl |
SHACL NodeShapes |
validate.py |
Runs pySHACL, prints the conformance report |
queries.py |
Five SPARQL reads over the derived graph |
run_all.py |
Single-command end-to-end run |
Committed outputs in data/ regenerate byte-identically from a clean clone.