Skip to content

Example datasets

Every dataset is a GraphDataset with the data, the true effects (where known) and a story card: print(ds.DESCR). Five generate their data offline; make_lastfm_promo downloads a public network on first use; load_insurance_experiment needs a free download.


Flu Town

Question. Does a flu shot reduce your sick days, and do your vaccinated neighbors protect you too?

Teaches. The difference between a direct and a peer effect, and why adjusting only for your own characteristics is not enough when confounders sit in your neighborhood.

Nodes 5,000 residents placed at random in a square town
Edges residents who live close to each other (random geometric graph, ~6 neighbors each)
Treatment flu_shot (0/1)
Outcome sick_days this season
Covariates age, chronic_condition, health_consciousness
Exposure map "sum": the number of vaccinated neighbors
True direct effect -2.0 sick days for getting the shot
True peer effect -0.5 sick days per vaccinated neighbor (herd protection)

What confounds. Health-conscious residents cluster together. The health-consciousness of your neighbors makes you more likely to get vaccinated (social norms) and also lowers your sick days (a healthier environment). Your own covariates do not capture it.

What naive methods get wrong. Regressing sick days on your own shot and your own covariates credits the shot with the neighborhood's healthiness: it reports roughly -2.4 days of protection instead of -2.0, and it has no way to report herd protection at all.

from graphdml import GraphDML
from graphdml.datasets import make_flu_town
from graphdml.baselines import compare_methods

ds = make_flu_town()
model = GraphDML(exposure=ds.exposure, random_state=0).fit(ds.data)
print(model.summary())
print(compare_methods(ds.data, exposure="sum", truth=ds.truth))

Knobs. confounding=0 removes the neighborhood confounding (naive methods then work for the direct effect); avg_neighbors changes density, and with it the size of the focal set.


Referral App

Question. Does a promo coupon increase a user's spending, and does it raise spending among their friends?

Teaches. How the exposure map defines what the peer effect means, and how hub-heavy social graphs shrink the focal set.

Nodes 8,000 app users
Edges friendships, grown by preferential attachment (a few influencers with many friends)
Treatment coupon (0/1)
Outcome monthly_spend in dollars
Covariates engagement_score, tenure_months, premium
Exposure map "mean": the share of friends who received a coupon (0 to 1)
True direct effect +$12 for receiving a coupon
True peer effect +\(40** going from 0% to 100% of friends with a coupon, i.e. **+\)4 per 10%

What confounds. The company's targeting model sends coupons to engaged users and to users whose friends are engaged, and engaged friends also raise spending directly.

Choosing the exposure map. The data were generated with the share of treated friends. Refit with exposure="sum" and the peer coefficient changes meaning (dollars per additional treated friend) and value, because the two maps disagree most for users with many friends. The exposure map is an assumption you bring from domain knowledge: ask "would two coupons among 4 friends matter as much as two among 400?"

from graphdml import GraphDML, two_hop_test
from graphdml.datasets import make_referral_app

ds = make_referral_app()
for exposure in ["mean", "sum"]:
    m = GraphDML(exposure=exposure, random_state=0).fit(ds.data)
    print(exposure, m.summary_frame().round(2), sep="\n")
print(two_hop_test(GraphDML(exposure="mean", random_state=0), ds.data))

Knobs. links_per_user changes density; confounding=0 removes targeting on friends.


Classroom Tutoring

Question. How much does an hour of tutoring raise a student's test score, and do students benefit when their friends get tutored?

Teaches. Continuous treatments, community structure, and what the focal set does to the estimand when effects differ across students.

Nodes 5,000 students in 200 classes of 25
Edges friendships, almost all within a class; higher-GPA students have more friends
Treatment tutoring_hours per week (continuous, >= 0)
Outcome test_score
Covariates prior_gpa, parent_education (0-3), free_lunch
Exposure map "mean": friends' average tutoring hours
True direct effect +1.5 points per hour of tutoring
True peer effect +0.4 points per hour of friends' average tutoring

What confounds. Struggling students get more tutoring; so do students whose friends have well-educated parents, and friends' prior GPA also lifts scores. Naive regression gets even the sign of the peer effect wrong.

The focal set and the estimand. GraphDML estimates effects from a focal set of students whose neighborhoods do not overlap; it favours students with fewer friends, who here have lower GPAs. With heterogeneous=True, tutoring helps lower-GPA students more, so the effect among focal students is larger than the school-wide average. Compare:

from graphdml import GraphDML
from graphdml.datasets import make_classroom_tutoring

ds = make_classroom_tutoring(heterogeneous=True)
m = GraphDML(exposure=ds.exposure, random_state=0).fit(ds.data)
print("school-wide truth:", ds.truth["ade"])
print("focal-set truth:  ", ds.focal_truth(m.focal_nodes_)["ade"])
print("estimate:         ", m.ade_)

With constant effects (the default) both truths coincide. With varying effects, report the estimand honestly: "the average effect among students like those in the focal set", and check how the focal set differs from everyone (m.diagnostics_["mean_degree_focal"]).

Known accuracy on this dataset. Over 200 draws, estimates run slightly toward zero (direct about 1.5%, peer about 10%, total about 3%) and 95% intervals cover the truth about 91-92% of the time, a little under nominal. The signs and sizes are right, and ignoring the network is far worse: it gets the sign of the peer effect wrong.


Homophily Trap

Question. Does taking an online course raise salary growth, and does it rub off on friends?

Teaches. A failure mode: when an unobserved trait drives both friendships and outcomes, GraphDML (like every method that assumes no unobserved confounding) finds a peer effect that does not exist. And how to catch it.

Nodes 5,000 professionals
Edges friendships formed mostly between people with similar (unobserved) ambition
Treatment took_course (0/1)
Outcome salary_growth (%)
Covariates age, education_years
Exposure map "mean": share of friends who took the course
True direct effect +1.0 percentage points
True peer effect 0: friends' courses do nothing
Hidden extras["latent_ambition"], never shown to the estimator
Negative control extras["negative_control"]: last year's salary growth

What goes wrong. Ambitious people take the course, earn raises, and befriend other ambitious people. Friends' enrolment is therefore a proxy for your own ambition, so it "predicts" your salary growth: GraphDML reports a large, confident, and entirely spurious peer effect, and an inflated direct effect.

How you would know. Last year's salary growth shares the confounder (ambition) but cannot have been caused by this year's course. Run the analysis on it as if it were the outcome; any "effect" is confounding:

from graphdml import GraphDML, negative_control_test
from graphdml.datasets import make_homophily_trap

ds = make_homophily_trap()
model = GraphDML(exposure=ds.exposure, random_state=0)
print(model.fit(ds.data).summary_frame())          # peer effect looks real ...
print(negative_control_test(model, ds.data, ds.extras["negative_control"]))  # ... FLAG

Note that two_hop_test does not catch this case: homophily makes friends-of-friends weaker proxies than friends, so the two-hop coefficient is small. Use negative controls and domain knowledge about how ties form; no estimator can rule out unobserved confounding on its own.


Signal Check (synthetic, planted effects)

Question. Does the estimator find a real effect when one exists, and stay quiet when none does?

Teaches. What a trustworthy estimate looks like. The data come in two versions with the same graph structure and the same strong network confounding:

Direct effect Peer effect
planted (make_signal_check()) +1.0 +0.6 (share of treated neighbors, 0 to 100%)
null twin (make_signal_check(direct_effect=0, peer_effect=0)) 0 0

Treatment and outcome both depend nonlinearly on a node's own covariates and on its neighbors' (mean and maximum), so adjusting only for own covariates, or ignoring the network, produces false signals. In the null twin, a method that ignores the network still reports effects, because confounding looks like a treatment effect. A correct method should report none.

Run the full check with graphdml.selftest(): it repeats both worlds many times and reports bias, interval coverage, power and false-alarm rates against fixed criteria.

import graphdml
print(graphdml.selftest())

LastFM Promo (semi-synthetic, real network)

Question. Does a concert promotion increase a user's listening hours, and does it spill over to friends who did not get it?

Teaches. How GraphDML behaves on a real social network: real topology, real covariates, and a heavily skewed degree distribution. Only the treatment and outcome are simulated, so the truth is known.

Nodes 7,624 LastFM users from Asian countries (real)
Edges 27,806 mutual-follow relationships (real; mean degree 7.3, max 216)
Covariates activity (log number of liked artists), taste_1..6 (SVD of liked artists) (real)
Treatment promo (0/1, simulated): targeted on own and friends' taste and activity
Outcome listening_hours per month (simulated)
Exposure map "mean": share of friends who received the promo
True direct effect +2.0 hours
True peer effect +3.0 hours going from 0% to 100% of friends promoted

What confounds. The targeting rule favours users whose friends share a particular taste, and friends' taste also drives listening (nonlinearly, through the friends' maximum of one taste dimension).

What to notice. The focal set (about 21% of users) is dominated by users with one or two friends: mean degree 1.3 versus 7.3 overall. With constant effects that is harmless. With effects that vary by popularity, it would change what is being estimated.

from graphdml import GraphDML
from graphdml.datasets import make_lastfm_promo

ds = make_lastfm_promo()          # downloads ~6.5 MB from SNAP on first use
print(GraphDML(exposure="mean", random_state=0).fit(ds.data).summary())
print(ds.extras["citation"])

Data: Rozemberczki & Sarkar (2020), via SNAP. graphdml downloads it on demand and does not redistribute it.


Insurance Experiment (real randomized experiment, real network)

Question. Does an intensive information session make rice farmers more likely to buy weather insurance, and does it spill over to their friends?

Source. Cai, de Janvry & Sadoulet (2015), Social Networks and the Decision to Insure, AEJ: Applied. Replication data: openICPSR project 113593 (CC BY 4.0; free account needed). load_insurance_experiment(path) reads the downloaded .dta files; graphdml does not ship the data.

Nodes 4,902 surveyed households in 47 administrative (173 natural) villages
Edges ~17,000 friend nominations (up to five per household), directed: named friend → nominator
Treatment intensive_session: randomized assignment to an intensive (vs simple) session
Design two session rounds three days apart (randomized); second-round households were randomly given information about first-round take-up, or none
Outcome bought_insurance (0/1)
Covariates household head and farm characteristics, design indicators, village dummies
Exposure share of named friends who attended a first-round intensive session (extras["exposure"])

Published estimates (Table 2). Direct effect of an intensive session on first-round households: 0.141 (SE 0.026). Peer effect for second-round households without information: 0.291 (SE 0.082) going from 0% to 100% of friends in first-round intensive sessions.

Exposure coding. The study's exposure counts every named friend in the denominator, and in the numerator counts friends recorded as first-round intensive participants. That includes 271 nominations of friends who are not among the surveyed households. Those friends are not nodes here, so the loader's exposure counts surveyed friends only. The two agree for 97% of households; extras["published_exposure"] holds the study's variable for comparison.

What to try.

from graphdml import GraphDML
from graphdml.datasets import load_insurance_experiment

ds = load_insurance_experiment("path/to/113593-V1/data/data")
ex = ds.extras
first_round = ~ex["second_round"]
m = GraphDML(exposure=None, estimation_nodes=first_round, random_state=0).fit(ds.data)
print(m.summary_frame())                                  # direct effect
peer_pop = ex["second_round"] & ex["info_none"]
m = GraphDML(exposure=ex["exposure"], estimation_nodes=peer_pop, random_state=0).fit(ds.data)
print(m.cluster_summary_frame(ex["natural_village"]))     # SEs clustered by village

Treatment was randomized, so the propensity model is correct by design and both scores are consistent. Clustered standard errors allow for village-level common shocks, as in the original study.


Toy Graph

A hand-drawn 30-node graph for pictures, not estimation: three 8-node clusters, two short chains linking them, and two isolated nodes. extras["positions"] holds a fixed layout.

Use it to see:

  • Dependency sets. With the one-hop exposure, node i's score involves node i and its neighbors. Two nodes can both be focal only if those sets do not overlap, i.e. they are at least three hops apart.
  • Focal sets. select_focal_set picks a maximal set of such nodes; isolated nodes are always focal (they block nothing).
  • Folds. Focal nodes are split into folds; each held-out fold's dependency sets are excluded from training the nuisance models.
import numpy as np
from graphdml.datasets import make_toy_graph
from graphdml.focal import dependency_matrix, select_focal_set

ds = make_toy_graph()
dep = dependency_matrix(30, [ds.data.pattern])
print(select_focal_set(dep, random_state=0))

Truth (for completeness): direct effect 1.0, peer effect 0.5 per treated neighbor. With 30 nodes the focal set has only a handful of members, far too few to estimate anything.