Skip to content

Open in Colab Launch Binder

Contact networks from stays

Question. Who meets whom, how persistent are those contacts, and which contacts reflect a real social tie rather than chance co-presence?

You will get a contact network built from co-presence, its degrees, clustering and edge overlap, a degree-preserving random baseline, and a classification of every contact as Friend, Bridge, Acquaintance or Random.

Prerequisites. Population measures.

Data. Brightkite check-ins (Stanford SNAP, 2008 to 2010): the first 300,000 rows (about 4 MB of the 60 MB file) with fastmob.data.load_brightkite, users with at least 100 check-ins (about 306). Important assumption: check-ins have no duration, so each is treated as a 30-minute stay, and two users are co-present when they are at the same H3 cell (about 0.7 km across) at overlapping times. That is a loose proxy for meeting. The notebook stops if the download fails.

%pip install -q 'fastmob>=0.3.4' pandas matplotlib

Stays on a shared grid

Contact analysis needs stays with a start, an end and a shared place id. We clean the check-ins (placeholder and invalid coordinates), give each a 30-minute duration and map them to H3 cells of resolution 8.

import fastmob
import numpy as np
import pandas as pd
from fastmob import social
from fastmob.data import load_brightkite

raw = load_brightkite(max_rows=300_000).to_pandas()

raw = raw[~(((raw["lat"] == 0) & (raw["lng"] == 0)) | (raw["lat"].abs() >= 90))]
counts = raw.groupby("uid").size()
checkins = raw[raw["uid"].isin(counts[counts >= 100].index)]
stays = fastmob.Staypoints(
    pd.DataFrame(
        {
            "uid": checkins["uid"],
            "lat": checkins["lat"],
            "lng": checkins["lng"],
            "started_at": checkins["datetime"],
            "finished_at": checkins["datetime"] + pd.Timedelta(minutes=30),
        }
    )
)
places, stays_at = stays.generate_global_locations(h3_resolution=8)
print(
    len(checkins),
    "check-ins by",
    checkins["uid"].nunique(),
    "users in",
    len(places),
    "places",
)
283543 check-ins by 306 users in 18659 places

Build the contact network

co_presence_graph_from_staypoints links every pair of users who overlapped in time and place for at least min_minutes_for_encounter minutes. The result holds an undirected graph (nodes are users), the number of days observed (time_steps) and each contact's persistence: the share of days on which it occurred.

contacts = social.co_presence_graph_from_staypoints(
    stays_at, places, min_minutes_for_encounter=5
)

graph = contacts.graph
degrees = graph.degrees()
print(
    f"{graph.node_count} users, {graph.edge_count} contacts over {contacts.time_steps} days"
)
print(
    f"degree: median {np.median(degrees):.0f}, max {degrees.max()}; users with no contact: {(degrees == 0).sum()}"
)
306 users, 1874 contacts over 941 days
degree: median 5, max 98; users with no contact: 71

The network has 306 users and 1,874 contacts over 941 days. The median user has 5 contacts, one has 98, and 71 of the 306 never overlapped with anyone. A contact needs the same cell at the same time, so people who check in at rarely used places have none.

Structure: clustering and neighbourhood overlap

clustering_coefficients gives, for each user, the share of their contacts that also meet each other. topological_overlap gives, for each contact, the Jaccard overlap of the two users' neighbourhoods: high when two people share many common contacts. Social ties usually show higher values than chance encounters.

clustering = social.clustering_coefficients(graph)
overlap = social.topological_overlap(graph)
summary = social.distribution_summary(clustering)

print("clustering:", {k: round(v, 2) for k, v in summary.items() if v is not None})
print(
    f"overlap: median {np.median(overlap):.2f}, 90th percentile {np.quantile(overlap, 0.9):.2f}"
)
clustering: {'count': 306, 'mean': 0.33, 'median': 0.33, 'std': 0.32, 'p10': 0.0, 'p90': 0.77}
overlap: median 0.23, 90th percentile 0.42

On average 33% of a user's contacts also meet each other (mean clustering 0.33; one tenth of the users have 0 and one tenth 0.77 or more). The median contact has a neighbourhood overlap of 0.23, and one tenth of the contacts exceed 0.42.

A random baseline

Is this much clustering surprising? degree_preserving_random_graph samples a random graph (Chung-Lu) in which each user has about the same number of contacts but meets people at random. Comparing the observed structure with it separates what the degrees alone explain from real structure. graph_from_edges builds a graph from a list of edges, here to check a tiny example by hand.

baseline = social.degree_preserving_random_graph(degrees, seed=1)
baseline_clustering = social.clustering_coefficients(baseline)
distance = social.safe_wasserstein(clustering, baseline_clustering)
triangle = social.graph_from_edges(4, [(0, 1), (1, 2), (0, 2), (2, 3)])

print(f"observed edges {graph.edge_count}, random baseline {baseline.edge_count}")
print(
    f"mean clustering: observed {clustering.mean():.2f}, baseline {baseline_clustering.mean():.2f}; Wasserstein distance {distance:.2f}"
)
print("tiny graph clustering:", np.round(social.clustering_coefficients(triangle), 2))
observed edges 1874, random baseline 1866
mean clustering: observed 0.33, baseline 0.20; Wasserstein distance 0.13
tiny graph clustering: [1.   1.   0.33 0.  ]

The random baseline has almost the same number of contacts (1,866 against 1,874) but a mean clustering of only 0.20 against 0.33 observed: the real network is more clustered than the degrees alone explain, mostly because many people check in at the same popular venues and so form groups. The tiny graph confirms the definition: nodes 0 and 1 have clustering 1 (their two contacts also meet), node 2 has 1/3 (of its three contacts only one pair meets) and node 3 has none.

Classify the contacts: Friends, Bridges, Acquaintances, Random

recast_from_staypoints runs the RECAST classifier. Each contact is judged by how persistent it is over time and how much neighbourhood overlap it has, each compared with a randomised version of the same data: a persistent contact with high overlap is a Friend; persistent with low overlap a Bridge; a contact with high overlap but low persistence an Acquaintance; the rest are Random. RecastResult.mask selects the contacts of one class.

from fastmob.social import RecastClass

recast = social.recast_from_staypoints(
    stays_at, places, min_minutes_for_encounter=5, random_replicates=5, seed=42
)
counts_by_class = {cls.name.title(): int(recast.mask(cls).sum()) for cls in RecastClass}

print(counts_by_class)
print(
    "thresholds: persistence",
    round(recast.persistence_threshold, 3),
    "| overlap",
    round(recast.overlap_threshold, 3),
)
{'Friends': 1, 'Bridges': 23, 'Acquaintances': 9, 'Random': 1841}
thresholds: persistence 0.035 | overlap 0.586

Of the 1,874 contacts, 1 is a Friend, 23 are Bridges, 9 are Acquaintances and 1,841 are Random. A contact counts as persistent or as overlapping when its value exceeds the 99.9th percentile of the same quantity in the randomised data (p_rnd=0.001): here persistence above 0.035 and overlap above 0.586. Chance explains nearly every contact in this data, which is the expected result for strangers checking in at the same venues.

Look inside: daily graphs and their randomisation

temporal_graph_from_staypoints returns the contacts day by day (RecastTemporalGraph); .event(i) is the graph of day i. rnd randomises one day's graph while preserving how many contacts each user had, and t_rnd does so for every day, random_replicates times: this is the null model the classifier uses. random_persistence samples how persistent contacts would be in the random baseline, RecastClass(code) turns a stored class code back into a class, and validate_recast_from_staypoints returns the classifier together with the observed and null distributions, for plotting the diagnostics.

temporal = social.temporal_graph_from_staypoints(stays_at, places)
busiest = max(
    range(temporal.time_steps), key=lambda i: len(temporal.event(i).edge_from)
)
day = temporal.event(busiest)
randomised_day = social.rnd(day, seed=1)
replicas = social.t_rnd(temporal, random_replicates=2, seed=1)
persistence_null = social.random_persistence(
    graph, degrees, time_steps=contacts.time_steps, seed=1
)
friends = RecastClass(0)
validation = social.validate_recast_from_staypoints(
    stays_at, places, random_replicates=2
)

print(
    f"day {busiest}: {len(day.edge_from)} contacts; randomised copy: {len(randomised_day.edge_from)}"
)
print(
    f"{len(replicas)} replicas of all {temporal.time_steps} days; class code 0 is {friends.name}"
)
print(
    f"random persistence sampled for {len(persistence_null)} contacts: median {np.median(persistence_null):.2f}; observed contacts: median {np.median(contacts.edge_persistence):.4f}, largest {max(contacts.edge_persistence):.3f}"
)
print(
    "validation holds:", ", ".join(m for m in dir(validation) if not m.startswith("_"))
)
day 357: 211 contacts; randomised copy: 202
2 replicas of all 941 days; class code 0 is FRIENDS
random persistence sampled for 1874 contacts: median 0.24; observed contacts: median 0.0011, largest 0.097
validation holds: classification, full_clustering, graph, overlap_null, overlap_observed, persistence_null, persistence_observed, random_only_clustering

The busiest day has 211 contacts, and its degree-preserving randomisation 202: degrees are preserved in expectation, not exactly. t_rnd returns one randomised copy of the whole period per replicate. The class code 0 maps back to Friends. random_persistence is a building block of the method: its values (median 0.24) come from a separate sampling of the baseline graph and are not directly comparable with the observed persistence of real contacts (median close to 0.001, at most about 10% of days); they are not the thresholds used by the classifier above. The validation object holds the observed and null distributions of persistence, overlap and clustering for the diagnostic plots of the RECAST method.

Interpretation and limits

  • The contacts here are co-presence at venues in a check-in dataset, not meetings. Two strangers checking in at the same popular place at the same time are linked. A real contact study needs positions recorded with a known frequency.
  • The 30-minute duration is an assumption that decides who overlaps. A shorter duration gives fewer contacts.
  • The classifier labels most contacts Random because chance co-presence dominates; the few Friends and Bridges are candidates, not confirmed ties.
  • Classes depend on min_minutes_for_encounter, the number of random replicates and the seed. Report them and check that the result is stable across seeds.

Next steps

co_presence_graph_from_staypoints · clustering_coefficients · topological_overlap · degree_preserving_random_graph · graph_from_edges · recast_from_staypoints · temporal_graph_from_staypoints · rnd · t_rnd · random_persistence · validate_recast_from_staypoints · distribution_summary · safe_wasserstein