Philip Jama

Articles /Network Graph Analysis /Part 8

Temporal Graph Networks

Modeling graphs that evolve over time

Temporal GraphsGraph Neural NetworksDeep LearningPython

Real networks change: friendships form and dissolve, citations accumulate, communication patterns shift with the calendar. Static graph analysis captures a snapshot, but temporal graph networks model the dynamics: how structure and features evolve, and how past interactions inform future ones. Building on the GNN foundations from Part 7 (Graph Neural Networks), this article introduces temporal graph representations, dynamic random walks, and the architectures (TGAT, TGN) that learn from time-stamped interactions.

Graphs That Evolve Over Time

A static graph is a single photograph; a temporal graph is a film. Edges appear and disappear, node attributes change, and the patterns that matter are often sequential: a burst of activity, a gradual drift, a sudden rewiring. Capturing these dynamics requires representations that encode when things happen, not just what is connected.

Dynamic Graph Representations

Two main representations:

  • Snapshot sequences: discretize time into windows and create a static graph per window. Simple and compatible with standard GNNs, but the window size is a hyperparameter that trades temporal resolution for graph density.
  • Continuous-time event streams: store each interaction as a timestamped event (u, v, t, features). More expressive but requires specialized architectures that process events incrementally.
Four network snapshots showing evolution over time with highlighted new edges
Four network snapshots showing evolution over time with highlighted new edges
Show Python source
import networkx as nx
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import random

FT_BG = '#FFF1E5'
FT_CLARET = '#990F3D'
FT_OXFORD = '#0F5499'
FT_TEAL = '#0D7680'

plt.rcParams.update({
    'figure.facecolor': FT_BG,
    'axes.facecolor': FT_BG,
    'savefig.facecolor': FT_BG,
    'font.family': 'sans-serif',
    'font.sans-serif': ['Helvetica Neue', 'Arial', 'sans-serif'],
    'axes.spines.top': False,
    'axes.spines.right': False,
})

random.seed(42)
np.random.seed(42)

nodes = list(range(20))
all_edges = []
windows = [(0, 10), (10, 20), (20, 30), (30, 40)]
window_labels = ['t=0-10', 't=10-20', 't=20-30', 't=30-40']

for t_start, t_end in windows:
    n_new = random.randint(5, 12)
    for _ in range(n_new):
        u, v = random.sample(nodes, 2)
        all_edges.append((u, v, random.uniform(t_start, t_end)))

pos = nx.spring_layout(nx.complete_graph(20), seed=42)

fig, axes = plt.subplots(2, 2, figsize=(12, 10))
prev_edges = set()
for idx, ((t_start, t_end), ax) in enumerate(zip(windows, axes.flat)):
    current_edges = set()
    for u, v, t in all_edges:
        if t <= t_end:
            current_edges.add((min(u, v), max(u, v)))
    new_edges = current_edges - prev_edges
    old_edges = current_edges - new_edges
    G = nx.Graph()
    G.add_nodes_from(nodes)
    G.add_edges_from(current_edges)
    nx.draw_networkx_nodes(G, pos, ax=ax, node_size=100, node_color=FT_OXFORD, alpha=0.7)
    if old_edges:
        nx.draw_networkx_edges(G, pos, edgelist=list(old_edges), ax=ax, alpha=0.2, width=1, edge_color='#999999')
    if new_edges:
        nx.draw_networkx_edges(G, pos, edgelist=list(new_edges), ax=ax, alpha=0.8, width=2, edge_color=FT_CLARET)
    ax.set_title(f'{window_labels[idx]} ({len(current_edges)} edges, +{len(new_edges)} new)',
                 fontsize=13, fontweight='600', color='#333333', pad=8)
    ax.set_axis_off()
    prev_edges = current_edges

fig.text(0.5, 0.97, 'Temporal Network Snapshots',
         ha='center', fontsize=17, fontweight='bold', color='#333333')
fig.text(0.5, 0.945, 'New edges in each window highlighted in claret',
         ha='center', fontsize=12, color='#666666')
fig.text(0.02, 0.01, 'Source: Philip Jama via pjama.github.io',
         fontsize=9, color='#999999', ha='left')
fig.tight_layout(rect=[0, 0.03, 1, 0.93])
fig.savefig('temporal_snapshots.png', dpi=150, bbox_inches='tight')

print('wrote temporal_snapshots.png')

Temporal Random Walks

Standard random walks ignore time: a walker can traverse an edge from 2020 and then one from 2015. Temporal random walks enforce chronological ordering: each step must follow an edge with a timestamp later than the previous step. This produces time-respecting paths that capture causal influence and temporal reachability. Temporal node2vec extends this to learn time-aware node embeddings.

Temporal Graph Attention (TGAT / TGN)

TGAT (Temporal Graph Attention) applies attention over time-stamped neighbors, weighting recent interactions more heavily using time-encoding functions. TGN (Temporal Graph Networks) adds a memory module: each node maintains a state vector updated after each interaction, allowing the model to remember long-term patterns. Both process events sequentially and produce dynamic node embeddings suitable for link prediction and anomaly detection.

Modeling an Evolving Communication Network

Consider an email or messaging dataset: each message is a timestamped directed edge. A temporal graph model can predict who will message whom next, detect emerging communities, or flag anomalous communication bursts. The evolution of network metrics over time: density, clustering, reciprocity. That evolution tells a story that no single snapshot captures.

Line charts showing network density, clustering, and components evolving over time
Line charts showing network density, clustering, and components evolving over time
Show Python source
import networkx as nx
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import random

FT_BG = '#FFF1E5'
FT_CLARET = '#990F3D'
FT_OXFORD = '#0F5499'
FT_TEAL = '#0D7680'
FT_CANDY = '#FF7FAA'

plt.rcParams.update({
    'figure.facecolor': FT_BG,
    'axes.facecolor': FT_BG,
    'savefig.facecolor': FT_BG,
    'font.family': 'sans-serif',
    'font.sans-serif': ['Helvetica Neue', 'Arial', 'sans-serif'],
    'axes.spines.top': False,
    'axes.spines.right': False,
})

random.seed(42)
np.random.seed(42)

nodes = list(range(20))
all_edges = []
windows = [(0, 10), (10, 20), (20, 30), (30, 40)]

for t_start, t_end in windows:
    n_new = random.randint(5, 12)
    for _ in range(n_new):
        u, v = random.sample(nodes, 2)
        all_edges.append((u, v, random.uniform(t_start, t_end)))

densities, avg_degrees, n_components, avg_clusterings = [], [], [], []
for t_start, t_end in windows:
    edges = set()
    for u, v, t in all_edges:
        if t <= t_end:
            edges.add((min(u, v), max(u, v)))
    G = nx.Graph()
    G.add_nodes_from(nodes)
    G.add_edges_from(edges)
    densities.append(nx.density(G))
    avg_degrees.append(np.mean([d for _, d in G.degree()]))
    n_components.append(nx.number_connected_components(G))
    avg_clusterings.append(nx.average_clustering(G))

x = list(range(len(windows)))
labels = ['t=0-10', 't=10-20', 't=20-30', 't=30-40']

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
metrics = [
    ('Density', densities), ('Avg Degree', avg_degrees),
    ('Components', n_components), ('Avg Clustering', avg_clusterings)
]
ft_colors = [FT_OXFORD, FT_CLARET, FT_TEAL, FT_CANDY]
for ax, (name, vals), color in zip(axes.flat, metrics, ft_colors):
    ax.plot(x, vals, 'o-', color=color, linewidth=2, markersize=8)
    ax.set_title(name, fontsize=13, fontweight='600', color='#333333', pad=6)
    ax.set_xticks(x)
    ax.set_xticklabels(labels)
    ax.set_ylabel(name, fontsize=11, color='#333333')
    ax.spines['left'].set_color('#cccccc')
    ax.spines['bottom'].set_color('#cccccc')
    ax.tick_params(colors='#666666', labelsize=10)
    ax.yaxis.grid(True, alpha=0.2, color='#999999')
    ax.set_axisbelow(True)

fig.text(0.5, 0.97, 'Network Metrics Over Time',
         ha='center', fontsize=17, fontweight='bold', color='#333333')
fig.text(0.5, 0.94, 'Cumulative snapshots across four time windows',
         ha='center', fontsize=12, color='#666666')
fig.text(0.02, 0.01, 'Source: Philip Jama via pjama.github.io',
         fontsize=9, color='#999999', ha='left')
fig.tight_layout(rect=[0, 0.03, 1, 0.93])
fig.savefig('temporal_metrics.png', dpi=150, bbox_inches='tight')

print('wrote temporal_metrics.png')

Applied Example: Customer Communication Graphs

Consider a B2B services company where account managers, support engineers, and executives communicate with client contacts via email, calls, and meetings. Each interaction is a timestamped edge between an internal employee and an external contact, carrying metadata: channel, duration, topic tags, sentiment. The resulting temporal bipartite graph encodes the full relationship history between the company and its customers.

A healthy account has regular, multi-threaded communication: the account manager checks in, support resolves tickets, an executive joins quarterly reviews. When those threads start thinning -- fewer touchpoints, longer gaps between interactions, conversations narrowing to a single channel -- the temporal pattern signals risk before any explicit complaint arrives.

Link prediction on this graph asks: given the communication history up to time t, which edges are likely to occur at t+1? A TGN trained on historical account data learns the cadence of healthy relationships. When the model predicts a touchpoint that fails to materialize -- a quarterly review that doesn't happen, a support thread that goes unanswered -- the gap between prediction and reality becomes a churn signal. The account team can intervene before the silence becomes a cancellation.

Anomaly Detection for Relationship Quality

The same graph supports anomaly detection from the opposite direction. Instead of predicting missing edges, flag edges that shouldn't be there -- or that deviate sharply from the learned pattern. A sudden spike in support tickets from a previously stable account, a burst of escalation emails bypassing the usual contacts, or a dormant executive relationship that abruptly reactivates: these are temporal anomalies that carry operational meaning.

The temporal dimension is essential here. A single support ticket is routine. Five support tickets in a week from an account that averages one per month is an anomaly that only surfaces when the model understands the baseline rhythm. Static graph analysis would count edges; temporal analysis understands tempo.

From Detection to Action

The practical value comes from connecting graph signals to business workflows. Link prediction scores can feed a CRM dashboard that ranks accounts by engagement risk. Anomaly scores can trigger alerts routed to the relevant account manager. Temporal community detection -- identifying clusters of client contacts who interact with different internal teams -- can surface accounts where communication is siloed and a single point of failure exists. The graph does not make the decision, but it surfaces the pattern that would otherwise remain buried in thousands of individual interactions.

Collaborate

If you're exploring related work and need hands-on help, I'm open to consulting and advisory. Get in touch