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_setpicks 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.