World Cup 2026 Network Analysis

Model FIFA World Cup 2026 teams as graphs and rank them with PageRank.

Introduction

30 Second Summary

Every World Cup, fans and pundits argue about which team is truly the strongest. Traditional statistics like goals scored or win percentage tell part of the story, but they miss the hidden connections between teams.

In this project, you will build a graph-based team profiling system that applies the same algorithm Google uses to rank web pages to FIFA World Cup 2026 match data. You will uncover power structures, gatekeeper teams, and specific vulnerabilities that simple statistics cannot detect.

What You'll Build

A Jupyter Notebook that transforms raw World Cup 2026 match statistics into a multi-dimensional network analysis pipeline using Python and NetworkX.

By the end of this project, you'll have:

  • Three directed weighted graphs representing offensive, territorial, and defensive dominance between 48 World Cup teams.
  • Graph-based power rankings using PageRank, betweenness centrality, and eigenvector centrality that reveal team strengths invisible to traditional stats.
  • Publication-ready network visualizations with community detection clusters and a radar chart comparing team profiles.
  • Secret Mission: A "Weakness Exploiter" tool that identifies which opponents a team should fear most based on graph structure.

Are there any prerequisites?

You should have basic familiarity with Python and pandas (loading data, working with DataFrames). You will also need a free Kaggle account to download the dataset.

Set Up and Load the Data

Before you can build graphs from World Cup data, you need the raw match statistics loaded into a format Python can work with. pandas DataFrames are the standard container for tabular data in Python, and that is where the match results will live.

In this step, you will create your analysis notebook, import the graph science libraries, and load the FIFA World Cup 2026 dataset so you can inspect what columns are available for building your three dominance graphs.

In this step, get ready to:
  • Import the graph analysis libraries into a new Jupyter Notebook.
  • Load the FIFA World Cup 2026 dataset into a pandas DataFrame.
  • Explore the dataset structure and identify the columns needed for the three graph layers.
Create your notebook and import libraries

Every analysis starts with importing the tools you need. NetworkX handles graph construction and algorithms, matplotlib draws the visualizations, and numpy powers the numerical operations behind the scenes.

  • In your Jupyter Notebook interface, click New (or the + button) and select Python 3 to create a new notebook.
  • Click the notebook name at the top of the page (it will say Untitled) to open the rename dialog.
  • Type world_cup_graph_analysis and confirm.
  • In the first code cell, add the following imports by pasting this code:
# Cell 1: Setup and Imports
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from itertools import islice

print("Libraries loaded successfully!")
print(f"NetworkX version: {nx.__version__}")
print(f"pandas version: {pd.__version__}")

What does this code do?

  • pandas (pd) loads and manipulates the match data as a table.
  • networkx (nx) builds and analyzes graphs, including PageRank and community detection.
  • matplotlib.pyplot and matplotlib.patches draw the network visualizations and legend patches.
  • numpy handles array math for layout positions and normalization.
  • itertools.islice is used later to slice community detection results.
  • Run the cell by pressing Shift+Enter.
  • You should see output confirming the libraries loaded, along with the NetworkX and pandas version numbers.

Seeing a ModuleNotFoundError?

If the cell fails with a missing module error, the library is not installed in the Python environment your notebook is using. Open a terminal and run:

pip install networkx pandas matplotlib numpy

Then restart your notebook kernel by clicking Kernel then Restart, and run the cell again.

Help me fix this ModuleNotFoundError

Load the FIFA World Cup 2026 dataset

With your libraries ready, you can bring in the match data. The CSV file you downloaded from Kaggle contains one row per match with scores, possession stats, and other performance metrics.

  • Add a new cell below the imports by pressing B (while in command mode) or clicking the + button in the toolbar.
  • Load the dataset by pasting this code into the new cell:
# Cell 2: Load and Explore Data
df = pd.read_csv("[[DATASET_PATH=\"path/to/fifa_world_cup_2026_dataset.csv\"]]")

print("Dataset shape:", df.shape)
print("\nColumn names:")
print(df.columns.tolist())
print("\nFirst few rows:")
df.head()

What does this code do?

  • pd.read_csv() reads the CSV file into a DataFrame called df.
  • df.shape prints how many rows (matches) and columns (statistics) the dataset contains.
  • df.columns.tolist() lists every column name so you can identify which ones map to goals, possession, shots, and passes.
  • df.head() shows the first five rows so you can visually confirm the data looks correct.

Finding your file path

Replace the path placeholder with the actual location of your downloaded CSV file. If you placed the CSV in the same folder as your notebook, you only need the filename (e.g., fifa_world_cup_2026_dataset.csv).

  • Run the cell with Shift+Enter.
  • You should see the dataset shape (rows and columns), the full list of column names, and a table preview of the first five matches.

Seeing a FileNotFoundError?

This means Python cannot find the CSV at the path you provided. Double-check that the file path matches where you saved the Kaggle download. If the file is in a different folder, provide the full path.

Help me fix this FileNotFoundError

Inspect available match statistics

Now that the data is loaded, you need to understand exactly what statistics are available per match. This determines how you will build the offensive, territorial, and defensive graph layers in the next step.

  • Add another new cell and paste this code to get a detailed view of the dataset:
# Cell 3: Inspect Available Statistics
print("\nDataset info:")
df.info()
print("\nSample match data:")
df.iloc[0]

What does this code do?

  • df.info() prints every column's name, data type, and how many non-null values it has. This helps you spot columns with missing data.
  • df.iloc[0] shows every field for a single match in a vertical format. This makes it easy to read the actual values and understand what each column represents.
  • Run the cell with Shift+Enter.
  • You should see a full list of columns with their data types, followed by every statistic for a single match printed vertically.
  • Look for columns related to team names (home_team, away_team), scores (home_team_score, away_team_score), and match statistics like possession, passes, and shots.

Why inspect the columns now?

The three graph layers you will build next each depend on different columns. Offensive dominance uses goal scores. Territorial dominance uses possession or passing stats. Defensive vulnerability uses goals conceded.

By inspecting the columns now, you know exactly which names to reference in your graph construction code. If a column name differs from what you expect, you can adapt before writing graph logic.

Column names look different from expected?

Kaggle datasets vary in naming conventions. Your columns might use underscores, camelCase, or abbreviations. As long as you can identify columns for team names, scores, and possession-related stats, you are ready to proceed. Take note of the exact names you see and use those in the next step.

Help me identify my dataset columns

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

ⓧ I'd like to double check the full code

Here is the complete code for all three cells in your notebook so far:

# Cell 1: Setup and Imports
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from itertools import islice

print("Libraries loaded successfully!")
print(f"NetworkX version: {nx.__version__}")
print(f"pandas version: {pd.__version__}")

# Cell 2: Load and Explore Data
df = pd.read_csv("path/to/fifa_world_cup_2026_dataset.csv")

print("Dataset shape:", df.shape)
print("\nColumn names:")
print(df.columns.tolist())
print("\nFirst few rows:")
df.head()

# Cell 3: Inspect Available Statistics
print("\nDataset info:")
df.info()
print("\nSample match data:")
df.iloc[0]

Your data is loaded and you know exactly which columns map to goals, possession, and defensive stats. Next up, you will transform these raw match results into three directed graphs that capture offensive dominance, territorial control, and defensive vulnerability.

Build the Multi-Layer Dominance Graphs

Your DataFrame is loaded with live match data from the FIFA World Cup 2026. Now it's time to transform that tabular data into something far more revealing: a graph structure that captures who dominated whom across multiple dimensions of play.

In this step, you'll build three separate directed graphs using NetworkX. Each graph captures a different dimension of team performance. One models scoring dominance, another models possession control, and the third reveals defensive vulnerability.

In this step, get ready to:
  • Build an offensive dominance directed graph weighted by goal difference.
  • Build a territorial dominance directed graph weighted by possession difference.
  • Build a defensive vulnerability directed graph weighted by goals conceded.
Build the offensive dominance graph

A DiGraph (directed graph) has edges that point in one direction. In the offensive graph, an edge points FROM the winning team TO the losing team. The edge weight represents the goal difference.

If two teams play multiple times, their edge weights accumulate. For draws, edges go in both directions with a default weight of 0.5, acknowledging that neither team dominated offensively.

  • Create a new cell in your notebook (Cell 4) and add the following code to build the offensive dominance graph:
# Cell 4: Build Offensive Dominance Graph
G_offense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        goal_diff = home_goals - away_goals
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(home_team, away_team, weight=goal_diff)
    elif away_goals > home_goals:
        goal_diff = away_goals - home_goals
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(away_team, home_team, weight=goal_diff)
    else:
        weight = 0.5
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += weight
        else:
            G_offense.add_edge(home_team, away_team, weight=weight)
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += weight
        else:
            G_offense.add_edge(away_team, home_team, weight=weight)

print(f"Offensive Graph: {G_offense.number_of_nodes()} nodes, {G_offense.number_of_edges()} edges")

What does this code do?

This code iterates through every match in your DataFrame and builds a directed graph based on who won.

  • G_offense = nx.DiGraph() creates an empty directed graph where edges have a direction (from winner to loser).
  • When a team wins, an edge is added from the winner to the loser with a weight equal to the goal difference. A 3-0 win creates a heavier edge than a 1-0 win.
  • The has_edge() check handles rematches. If two teams play again, the edge weight accumulates rather than being overwritten.
  • For draws, edges go in both directions with a weight of 0.5. This captures that both teams held their ground without giving either full dominance credit.
  • Run the cell by pressing Shift+Enter.
  • You should see output like Offensive Graph: X nodes, Y edges where X is up to 48 (the number of teams in the tournament) and Y reflects how many dominance relationships exist.

Seeing a KeyError?

If you get a KeyError, the column names in your dataset don't match what the code expects. Check the column names you printed in Cell 2.

  • If your dataset uses different column names for team names or scores, update the match['home_team'] and similar references to match your actual column names.
  • Make sure you ran Cells 1-3 first so that df is defined in memory.

Help me fix a KeyError in my offensive graph code.

Build the territorial dominance graph

The offensive graph only captures who scored more goals. But football has another dimension of dominance: which team controlled the ball. A team can dominate possession but still lose on the scoreboard.

The territorial graph creates a directed edge from the team with higher possession to the other, weighted by the percentage difference. This separates ball control from finishing ability.

  • Create a new cell (Cell 5) and add the following code to build the territorial dominance graph:
# Cell 5: Build Territorial Dominance Graph
G_territory = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']

    home_possession = match.get('home_possession', 50)
    away_possession = match.get('away_possession', 50)

    possession_diff = abs(home_possession - away_possession)
    if possession_diff > 0:
        if home_possession > away_possession:
            if G_territory.has_edge(home_team, away_team):
                G_territory[home_team][away_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(home_team, away_team, weight=possession_diff)
        else:
            if G_territory.has_edge(away_team, home_team):
                G_territory[away_team][home_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(away_team, home_team, weight=possession_diff)

print(f"Territorial Graph: {G_territory.number_of_nodes()} nodes, {G_territory.number_of_edges()} edges")

What does this code do?

  • match.get('home_possession', 50) safely reads the possession column. If your dataset doesn't have it, it defaults to 50 (a neutral split) so the code doesn't crash.
  • The edge points from the higher-possession team to the lower-possession team. A 65%-35% possession split creates an edge with weight 30.
  • If both teams had exactly 50% possession, no edge is created for that match. Only meaningful possession differences generate graph structure.
  • Run the cell by pressing Shift+Enter.
  • You should see output like Territorial Graph: X nodes, Y edges printed below the cell.

What if my dataset doesn't have possession data?

The code handles this gracefully. If the possession columns don't exist, every match defaults to 50-50, meaning no edges are created and the graph stays empty. If you see 0 nodes and 0 edges, check your dataset columns for an alternative like total passes completed or pass accuracy. You can swap the column names to use whatever territorial metric is available.

Seeing 0 nodes and 0 edges?

  • Your dataset likely uses different column names for possession. Check the columns you printed in Cell 2 and look for possession-related fields.
  • Update home_possession and away_possession references to match your actual column names.

Help me find the right possession column in my dataset.

Build the defensive vulnerability graph

The first two graphs capture positive dominance. The defensive graph flips the perspective. It models vulnerability by directing edges TOWARD the team that conceded more goals.

A team with many incoming edges in this graph is one that has been scored on repeatedly. The edge weight is the number of goals conceded, not the goal difference. This means even a team that wins 4-3 still picks up a vulnerability edge because they conceded 3 goals.

  • Create a new cell (Cell 6) and add the following code to build the defensive vulnerability graph:
# Cell 6: Build Defensive Vulnerability Graph
G_defense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        if G_defense.has_edge(home_team, away_team):
            G_defense[home_team][away_team]['weight'] += home_goals
        else:
            G_defense.add_edge(home_team, away_team, weight=home_goals)
    elif away_goals > home_goals:
        if G_defense.has_edge(away_team, home_team):
            G_defense[away_team][home_team]['weight'] += away_goals
        else:
            G_defense.add_edge(away_team, home_team, weight=away_goals)
    else:
        if home_goals > 0:
            if G_defense.has_edge(home_team, away_team):
                G_defense[home_team][away_team]['weight'] += home_goals
            else:
                G_defense.add_edge(home_team, away_team, weight=home_goals)
            if G_defense.has_edge(away_team, home_team):
                G_defense[away_team][home_team]['weight'] += away_goals
            else:
                G_defense.add_edge(away_team, home_team, weight=away_goals)

print(f"Defensive Vulnerability Graph: {G_defense.number_of_nodes()} nodes, {G_defense.number_of_edges()} edges")

What does this code do?

  • When the home team wins, an edge points from the home team to the away team (the team that conceded more). The weight is the number of goals scored by the winner, not the difference.
  • For draws where both teams scored (e.g., 2-2), edges go in both directions. Both teams conceded, so both receive vulnerability edges.
  • A 0-0 draw creates no edges because if home_goals > 0 filters out scoreless matches. No goals conceded means no vulnerability.

Why model vulnerability separately from offense?

A team like Spain might dominate offensively (high offensive PageRank) but also concede frequently in open play. Separating these dimensions lets you spot teams that look strong on paper but have structural weaknesses. Later, you'll combine all three layers into a composite score that accounts for this tension.

  • Run the cell by pressing Shift+Enter.
  • You should see output like Defensive Vulnerability Graph: X nodes, Y edges printed below the cell.
  • Verify the three graphs make sense: the offensive and defensive graphs should have similar node counts (teams that played), while the territorial graph might have fewer if possession data is sparse.

Graph node count seems too low?

  • If you see significantly fewer than 48 nodes, your DataFrame might only contain a subset of matches. Check df.shape to confirm how many rows (matches) are loaded.
  • The dataset updates as the tournament progresses. Fewer than 48 teams is normal if some teams haven't played yet or if your download is from early in the group stage.

Help me check why my graph has fewer nodes than expected.

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook with all three graph cells (Cells 4-6) run successfully.

ⓧ I'd like to double check the full code

Here's the complete notebook up to this point. Make sure your cells match:

# Cell 1: Setup and Imports
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from itertools import islice

print("Libraries loaded successfully!")
print(f"NetworkX version: {nx.__version__}")
print(f"pandas version: {pd.__version__}")

# Cell 2: Load and Explore Data
df = pd.read_csv("path/to/fifa_world_cup_2026_dataset.csv")

print("Dataset shape:", df.shape)
print("\nColumn names:")
print(df.columns.tolist())
print("\nFirst few rows:")
df.head()

# Cell 3: Inspect Available Statistics
print("\nDataset info:")
df.info()
print("\nSample match data:")
df.iloc[0]

# Cell 4: Build Offensive Dominance Graph
G_offense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        goal_diff = home_goals - away_goals
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(home_team, away_team, weight=goal_diff)
    elif away_goals > home_goals:
        goal_diff = away_goals - home_goals
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(away_team, home_team, weight=goal_diff)
    else:
        weight = 0.5
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += weight
        else:
            G_offense.add_edge(home_team, away_team, weight=weight)
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += weight
        else:
            G_offense.add_edge(away_team, home_team, weight=weight)

print(f"Offensive Graph: {G_offense.number_of_nodes()} nodes, {G_offense.number_of_edges()} edges")

# Cell 5: Build Territorial Dominance Graph
G_territory = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']

    home_possession = match.get('home_possession', 50)
    away_possession = match.get('away_possession', 50)

    possession_diff = abs(home_possession - away_possession)
    if possession_diff > 0:
        if home_possession > away_possession:
            if G_territory.has_edge(home_team, away_team):
                G_territory[home_team][away_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(home_team, away_team, weight=possession_diff)
        else:
            if G_territory.has_edge(away_team, home_team):
                G_territory[away_team][home_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(away_team, home_team, weight=possession_diff)

print(f"Territorial Graph: {G_territory.number_of_nodes()} nodes, {G_territory.number_of_edges()} edges")

# Cell 6: Build Defensive Vulnerability Graph
G_defense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        if G_defense.has_edge(home_team, away_team):
            G_defense[home_team][away_team]['weight'] += home_goals
        else:
            G_defense.add_edge(home_team, away_team, weight=home_goals)
    elif away_goals > home_goals:
        if G_defense.has_edge(away_team, home_team):
            G_defense[away_team][home_team]['weight'] += away_goals
        else:
            G_defense.add_edge(away_team, home_team, weight=away_goals)
    else:
        if home_goals > 0:
            if G_defense.has_edge(home_team, away_team):
                G_defense[home_team][away_team]['weight'] += home_goals
            else:
                G_defense.add_edge(home_team, away_team, weight=home_goals)
            if G_defense.has_edge(away_team, home_team):
                G_defense[away_team][home_team]['weight'] += away_goals
            else:
                G_defense.add_edge(away_team, home_team, weight=away_goals)

print(f"Defensive Vulnerability Graph: {G_defense.number_of_nodes()} nodes, {G_defense.number_of_edges()} edges")

You now have three distinct views of the same tournament encoded as graphs. Next up, you'll apply powerful algorithms like PageRank and centrality measures to extract hidden power rankings from these graph structures.

Compute Graph-Based Power Rankings

Your three dominance graphs are built. Each one encodes a different dimension of team performance as a network of directed, weighted edges. The raw structure is in place, but the real insights are still locked inside.

In this step, you will run graph algorithms that extract hidden power structures from your networks. The headline algorithm is PageRank, the same algorithm Google uses to rank web pages. Instead of ranking pages by who links to them, you will rank teams by who they dominate and how strong those dominated teams are.

In this step, get ready to:
  • Apply PageRank to compute transitive dominance scores across all three graph layers.
  • Compute betweenness and eigenvector centrality to identify gatekeeper and influential teams.
  • Combine all metrics into a unified rankings DataFrame with a composite power score.
Apply PageRank to each graph layer

A simple win count treats all victories equally. PageRank goes deeper by considering transitivity. A team that beats other strong teams earns a higher score than a team that only beats weak opponents.

  • Create a new cell in your notebook (Cell 7) and compute PageRank for all three graphs by pasting this code:
# Cell 7: Compute PageRank for Each Layer
pr_offense = nx.pagerank(G_offense, alpha=0.85, weight='weight')
pr_territory = nx.pagerank(G_territory, alpha=0.85, weight='weight')
pr_defense = nx.pagerank(G_defense, alpha=0.85, weight='weight')

print("Top 10 by Offensive PageRank:")
for team, score in sorted(pr_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

What does this code do?

nx.pagerank() runs the PageRank algorithm on each directed graph. The alpha=0.85 parameter is the damping factor, meaning there is an 85% chance the algorithm follows an edge and a 15% chance it jumps to a random node. This prevents dead-end teams from trapping all the score.

The weight='weight' parameter tells the algorithm to consider edge weights (goal difference, possession gap, goals conceded) when distributing scores. Heavier edges transfer more dominance.

  • Run the cell.
  • You should see a ranked list of the top 10 teams by offensive PageRank, each with a decimal score. Teams that beat other high-ranking teams will appear near the top.

Why three separate PageRank computations?

Each graph layer captures a different dimension of dominance. A team might rank highly in offensive PageRank (they beat strong teams by wide margins) but poorly in territorial PageRank (they do not control possession). Running PageRank on each layer separately preserves these dimensional differences.

Seeing a PowerIterationFailedConvergence error?

This can happen if your graph has isolated nodes or very sparse connections. Check that your graphs from the previous step have edges by printing G_offense.number_of_edges(). If the count is 0, re-run your graph-building cells first.

Help me debug a PageRank convergence error on my World Cup graph.

Compute betweenness and eigenvector centrality

PageRank reveals who dominates. But tournaments also have "gatekeeper" teams that disrupt the expected order. Betweenness centrality finds these teams. They sit on the shortest paths between other teams, meaning they connect the strong cluster to the weak cluster and can upset either.

  • Create a new cell (Cell 8) and compute betweenness and eigenvector centrality by pasting this code:
# Cell 8: Compute Betweenness and Eigenvector Centrality
bc_offense = nx.betweenness_centrality(G_offense, weight='weight')
ec_offense = nx.eigenvector_centrality(G_offense, max_iter=1000, weight='weight')

print("\nTop 10 by Betweenness Centrality (Gatekeeper Teams):")
for team, score in sorted(bc_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

print("\nTop 10 by Eigenvector Centrality:")
for team, score in sorted(ec_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

What do these centrality metrics reveal?

Betweenness centrality (bc_offense) measures how often a team appears on the shortest path between other teams. High-betweenness teams are the unpredictable disruptors. They bridge strong and weak clusters.

Eigenvector centrality (ec_offense) measures influence. A team scores highly when it is connected to other highly-connected teams. The max_iter=1000 parameter gives the power iteration algorithm extra room to converge on sparse tournament graphs.

  • Run the cell.
  • You should see two ranked lists: the top 10 gatekeeper teams by betweenness centrality and the top 10 most influential teams by eigenvector centrality. Notice which teams appear in both lists versus only one.

Eigenvector centrality not converging?

If the algorithm does not converge even with max_iter=1000, your graph may be too sparse (too few matches played so far). Try increasing to max_iter=5000. If it still fails, you can substitute Katz centrality as a fallback: nx.katz_centrality(G_offense, weight='weight').

Help me fix eigenvector centrality convergence on my tournament graph.

Build the combined rankings DataFrame

Each metric tells a different story. Offensive PageRank highlights dominant attackers. Territorial PageRank finds possession kings. Defensive vulnerability exposes leaky defences. To produce a single power ranking, you need to normalize and combine these scores.

  • Create a new cell (Cell 9) and build the combined rankings DataFrame by pasting this code:
# Cell 9: Build Combined Rankings DataFrame
all_teams = list(G_offense.nodes())

rankings = pd.DataFrame({
    'team': all_teams,
    'offensive_pagerank': [pr_offense.get(t, 0) for t in all_teams],
    'territorial_pagerank': [pr_territory.get(t, 0) for t in all_teams],
    'defensive_vulnerability': [pr_defense.get(t, 0) for t in all_teams],
    'betweenness': [bc_offense.get(t, 0) for t in all_teams],
    'eigenvector': [ec_offense.get(t, 0) for t in all_teams],
})

# Normalize each column to 0-1 range
for col in rankings.columns[1:]:
    col_min = rankings[col].min()
    col_max = rankings[col].max()
    if col_max > col_min:
        rankings[col + '_norm'] = (rankings[col] - col_min) / (col_max - col_min)
    else:
        rankings[col + '_norm'] = 0

# Composite score (high offense + territory + eigenvector, LOW defense vulnerability = good)
rankings['composite_score'] = (
    rankings['offensive_pagerank_norm'] +
    rankings['territorial_pagerank_norm'] +
    rankings['eigenvector_norm'] -
    rankings['defensive_vulnerability_norm']
) / 4

rankings = rankings.sort_values('composite_score', ascending=False).reset_index(drop=True)
print("\nGraph-Based Power Rankings:")
print(rankings[['team', 'offensive_pagerank', 'territorial_pagerank',
                'defensive_vulnerability', 'composite_score']].head(15).to_string())

What does this code do?

First, it collects all metrics into a single pandas DataFrame with one row per team. Each column holds a raw centrality score from the corresponding graph or algorithm.

Next, it normalizes every metric column to a 0-1 range using min-max scaling. This ensures no single metric dominates the composite just because its raw values happen to be larger.

Finally, the composite score formula rewards high offensive, territorial, and eigenvector scores but penalizes high defensive vulnerability. A team that dominates and controls the ball but also leaks goals gets a balanced score rather than an inflated one.

  • Run the cell.
  • You should see a printed table of the top 15 teams ranked by composite score, with columns for offensive PageRank, territorial PageRank, defensive vulnerability, and the composite score.
  • Compare your top 5 teams with FIFA's official rankings. Notice where the graph-based analysis diverges. Teams that beat strong opponents but lack marquee names may rank higher here than in traditional systems.

Seeing all zeros in the composite score?

This happens when the normalized columns are all zero. Check that your PageRank dictionaries (pr_offense, pr_territory, pr_defense) contain non-zero values by running max(pr_offense.values()) in a quick cell. If the max is 0, your graphs have no edges. Re-run Cells 4-6.

Help me debug why my composite rankings are all zeros.

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook with all cells through Cell 9.

ⓧ I'd like to double check the full code

Here is the full notebook through Cell 9. Compare your cells against this reference.

# Cell 1: Setup and Imports
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from itertools import islice

print("Libraries loaded successfully!")
print(f"NetworkX version: {nx.__version__}")
print(f"pandas version: {pd.__version__}")

# Cell 2: Load and Explore Data
df = pd.read_csv("path/to/fifa_world_cup_2026_dataset.csv")

print("Dataset shape:", df.shape)
print("\nColumn names:")
print(df.columns.tolist())
print("\nFirst few rows:")
df.head()

# Cell 3: Inspect Available Statistics
print("\nDataset info:")
df.info()
print("\nSample match data:")
df.iloc[0]

# Cell 4: Build Offensive Dominance Graph
G_offense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        goal_diff = home_goals - away_goals
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(home_team, away_team, weight=goal_diff)
    elif away_goals > home_goals:
        goal_diff = away_goals - home_goals
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(away_team, home_team, weight=goal_diff)
    else:
        weight = 0.5
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += weight
        else:
            G_offense.add_edge(home_team, away_team, weight=weight)
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += weight
        else:
            G_offense.add_edge(away_team, home_team, weight=weight)

print(f"Offensive Graph: {G_offense.number_of_nodes()} nodes, {G_offense.number_of_edges()} edges")

# Cell 5: Build Territorial Dominance Graph
G_territory = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']

    home_possession = match.get('home_possession', 50)
    away_possession = match.get('away_possession', 50)

    possession_diff = abs(home_possession - away_possession)
    if possession_diff > 0:
        if home_possession > away_possession:
            if G_territory.has_edge(home_team, away_team):
                G_territory[home_team][away_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(home_team, away_team, weight=possession_diff)
        else:
            if G_territory.has_edge(away_team, home_team):
                G_territory[away_team][home_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(away_team, home_team, weight=possession_diff)

print(f"Territorial Graph: {G_territory.number_of_nodes()} nodes, {G_territory.number_of_edges()} edges")

# Cell 6: Build Defensive Vulnerability Graph
G_defense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        if G_defense.has_edge(home_team, away_team):
            G_defense[home_team][away_team]['weight'] += home_goals
        else:
            G_defense.add_edge(home_team, away_team, weight=home_goals)
    elif away_goals > home_goals:
        if G_defense.has_edge(away_team, home_team):
            G_defense[away_team][home_team]['weight'] += away_goals
        else:
            G_defense.add_edge(away_team, home_team, weight=away_goals)
    else:
        if home_goals > 0:
            if G_defense.has_edge(home_team, away_team):
                G_defense[home_team][away_team]['weight'] += home_goals
            else:
                G_defense.add_edge(home_team, away_team, weight=home_goals)
            if G_defense.has_edge(away_team, home_team):
                G_defense[away_team][home_team]['weight'] += away_goals
            else:
                G_defense.add_edge(away_team, home_team, weight=away_goals)

print(f"Defensive Vulnerability Graph: {G_defense.number_of_nodes()} nodes, {G_defense.number_of_edges()} edges")

# Cell 7: Compute PageRank for Each Layer
pr_offense = nx.pagerank(G_offense, alpha=0.85, weight='weight')
pr_territory = nx.pagerank(G_territory, alpha=0.85, weight='weight')
pr_defense = nx.pagerank(G_defense, alpha=0.85, weight='weight')

print("Top 10 by Offensive PageRank:")
for team, score in sorted(pr_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

# Cell 8: Compute Betweenness and Eigenvector Centrality
bc_offense = nx.betweenness_centrality(G_offense, weight='weight')
ec_offense = nx.eigenvector_centrality(G_offense, max_iter=1000, weight='weight')

print("\nTop 10 by Betweenness Centrality (Gatekeeper Teams):")
for team, score in sorted(bc_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

print("\nTop 10 by Eigenvector Centrality:")
for team, score in sorted(ec_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

# Cell 9: Build Combined Rankings DataFrame
all_teams = list(G_offense.nodes())

rankings = pd.DataFrame({
    'team': all_teams,
    'offensive_pagerank': [pr_offense.get(t, 0) for t in all_teams],
    'territorial_pagerank': [pr_territory.get(t, 0) for t in all_teams],
    'defensive_vulnerability': [pr_defense.get(t, 0) for t in all_teams],
    'betweenness': [bc_offense.get(t, 0) for t in all_teams],
    'eigenvector': [ec_offense.get(t, 0) for t in all_teams],
})

for col in rankings.columns[1:]:
    col_min = rankings[col].min()
    col_max = rankings[col].max()
    if col_max > col_min:
        rankings[col + '_norm'] = (rankings[col] - col_min) / (col_max - col_min)
    else:
        rankings[col + '_norm'] = 0

rankings['composite_score'] = (
    rankings['offensive_pagerank_norm'] +
    rankings['territorial_pagerank_norm'] +
    rankings['eigenvector_norm'] -
    rankings['defensive_vulnerability_norm']
) / 4

rankings = rankings.sort_values('composite_score', ascending=False).reset_index(drop=True)
print("\nGraph-Based Power Rankings:")
print(rankings[['team', 'offensive_pagerank', 'territorial_pagerank',
                'defensive_vulnerability', 'composite_score']].head(15).to_string())

You now have a complete set of graph-based power rankings that go far beyond simple win counts. Next up, you will visualize these networks and build radar charts that profile individual teams across all dimensions.

Detect Communities and Visualize the Network

You now have individual power rankings for every team, but numbers alone miss the bigger picture. Teams don't exist in isolation. They cluster into natural performance tiers where teams within a tier beat each other but rarely beat teams in the tier above.

In this step, you'll use community detection to automatically discover these tiers from the graph structure. Then you'll visualize the full network and build a radar chart that compares any two teams head-to-head across all five metrics.

In this step, get ready to:
  • Apply Girvan-Newman community detection to discover natural team tiers.
  • Visualize the offensive dominance graph as a publication-ready network diagram.
  • Build a radar chart comparing two teams across all graph metrics.
Apply Girvan-Newman community detection

The Girvan-Newman algorithm finds communities by progressively removing the edges with the highest betweenness centrality. When you remove the "bridge" edges that connect different clusters, the graph naturally splits into groups of tightly-connected teams.

  • Create a new cell in your notebook (Cell 10) and run community detection on the offensive graph by pasting this code:
# Cell 10: Community Detection (Girvan-Newman)
communities_generator = nx.community.girvan_newman(G_offense)
top_level_communities = next(communities_generator)

community_list = sorted(top_level_communities, key=len, reverse=True)

print(f"\nDetected {len(community_list)} communities:")
for i, community in enumerate(community_list):
    print(f"  Tier {i+1} ({len(community)} teams): {sorted(community)}")

# Assign community labels to teams
team_community = {}
for i, community in enumerate(community_list):
    for team in community:
        team_community[team] = i

What does this code do?

`nx.community.girvan_newman(G_offense)` returns a generator that yields community splits at each step. Calling `next()` grabs the first (top-level) split into communities.

`community_list` sorts those communities by size so Tier 1 is the largest group. The `team_community` dictionary maps each team name to its tier index, which you'll use for coloring nodes in the visualization.

  • Run the cell.
  • You should see output listing the detected communities with team names grouped into tiers. Each tier shows how many teams it contains.

Why Girvan-Newman instead of Louvain?

Girvan-Newman works directly on weighted directed graphs without requiring you to pre-specify the number of clusters. It uses edge-betweenness removal to naturally reveal hierarchical tiers. This makes it ideal for tournament data where you don't know ahead of time how many performance tiers exist.

Seeing very few or very many communities?

The number of communities depends on how many matches are in your dataset. If you see only 2 large communities, your graph might be too dense. If you see many single-team communities, the graph may be sparse. Both are valid results that reflect the actual tournament data.

Help me understand my community detection results.

Visualize the offensive dominance network

A well-designed network visualization encodes multiple data dimensions into a single image. You'll map PageRank to node size, community membership to color, and goal difference to edge thickness.

  • Create a new cell (Cell 11) and build the network visualization by pasting this code:
# Cell 11: Visualize the Offensive Dominance Network
plt.figure(figsize=(14, 10))

pos = nx.spring_layout(G_offense, seed=42, k=2)

# Node sizes proportional to PageRank
node_sizes = [pr_offense.get(node, 0) * 8000 + 200 for node in G_offense.nodes()]

# Node colors by community
colors = plt.cm.Set3(np.linspace(0, 1, len(community_list)))
node_colors = [colors[team_community.get(node, 0)] for node in G_offense.nodes()]

# Edge widths proportional to weight
edge_weights = [G_offense[u][v]['weight'] for u, v in G_offense.edges()]
max_weight = max(edge_weights) if edge_weights else 1
edge_widths = [w / max_weight * 3 for w in edge_weights]

nx.draw_networkx_nodes(G_offense, pos, node_size=node_sizes,
                       node_color=node_colors, alpha=0.8)
nx.draw_networkx_edges(G_offense, pos, width=edge_widths,
                       alpha=0.3, edge_color='gray',
                       arrowsize=10, arrowstyle='->')
nx.draw_networkx_labels(G_offense, pos, font_size=7, font_weight='bold')

# Legend
legend_patches = [mpatches.Patch(color=colors[i], label=f'Tier {i+1}')
                  for i in range(min(len(community_list), 5))]
plt.legend(handles=legend_patches, loc='upper left', fontsize=10)

plt.title("FIFA World Cup 2026 - Offensive Dominance Network\n(Node size = PageRank, Color = Community Tier)",
          fontsize=13)
plt.axis('off')
plt.tight_layout()
plt.savefig('offensive_dominance_network.png', dpi=150, bbox_inches='tight')
plt.show()

What does this visualization encode?

  • `nx.spring_layout` positions nodes using a force-directed algorithm. Connected nodes attract, unconnected nodes repel. The `seed=42` ensures reproducible layouts and `k=2` controls spacing.
  • Node size reflects offensive PageRank. Bigger circles mean more dominant teams.
  • Node color shows community tier membership from the Girvan-Newman detection.
  • Edge thickness reflects goal difference weight. Thicker arrows mean more decisive victories.
  • Run the cell.
  • You should see a network diagram with colored clusters of teams, directed arrows between them, and larger nodes for the top-ranked teams. The file offensive_dominance_network.png is saved to your working directory.

Graph looks like a tangled mess?

With 48 teams and many edges, some overlap is expected. Look for the color clusters and the relative node sizes rather than trying to trace individual arrows. The high-PageRank teams (large nodes) should be visually prominent.

If the graph is entirely unreadable, try increasing the `k` parameter in `nx.spring_layout` from `2` to `3` or `4` to spread nodes further apart.

Help me improve my network graph layout.

Build a radar chart for head-to-head team comparison

The network graph shows the full tournament picture. Now you need a tool for comparing specific teams. A radar chart plots each team's normalized scores across five axes, making strengths and weaknesses immediately visible.

  • Create a new cell (Cell 12) and define the radar chart function by pasting this code:
# Cell 12: Radar Chart - Team Profile Comparison
def radar_chart(team1, team2, rankings_df):
    categories = ['Offensive\nPageRank', 'Territorial\nPageRank',
                  'Defensive\nVulnerability', 'Betweenness\n(Gatekeeper)',
                  'Eigenvector\nCentrality']
    norm_cols = ['offensive_pagerank_norm', 'territorial_pagerank_norm',
                 'defensive_vulnerability_norm', 'betweenness_norm',
                 'eigenvector_norm']

    team1_data = rankings_df[rankings_df['team'] == team1][norm_cols].values.flatten()
    team2_data = rankings_df[rankings_df['team'] == team2][norm_cols].values.flatten()

    angles = np.linspace(0, 2 * np.pi, len(categories), endpoint=False).tolist()
    angles += angles[:1]

    team1_values = team1_data.tolist() + [team1_data[0]]
    team2_values = team2_data.tolist() + [team2_data[0]]

    fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
    ax.plot(angles, team1_values, 'o-', linewidth=2, label=team1, color='#1f77b4')
    ax.fill(angles, team1_values, alpha=0.15, color='#1f77b4')
    ax.plot(angles, team2_values, 'o-', linewidth=2, label=team2, color='#ff7f0e')
    ax.fill(angles, team2_values, alpha=0.15, color='#ff7f0e')

    ax.set_xticks(angles[:-1])
    ax.set_xticklabels(categories, fontsize=10)
    ax.set_ylim(0, 1)
    ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1), fontsize=11)
    ax.set_title(f"Team Profile: {team1} vs {team2}", fontsize=13, pad=20)

    plt.tight_layout()
    plt.savefig(f'radar_{team1}_vs_{team2}.png', dpi=150, bbox_inches='tight')
    plt.show()

# Compare two teams (adjust names based on your dataset)
radar_chart('Spain', 'Ecuador', rankings)

What does this code do?

The `radar_chart` function pulls each team's normalized scores across five dimensions from the `rankings` DataFrame. It plots them on a polar coordinate system where each axis represents one metric (0 to 1 scale).

The filled area makes it easy to spot which team dominates which dimension. A team with high defensive vulnerability and low offensive PageRank has a lopsided shape that immediately signals weakness.

  • Run the cell.
  • You should see a polar radar chart with two overlapping colored shapes comparing Spain and Ecuador across five graph metrics. The file radar_Spain_vs_Ecuador.png is saved to your working directory.

Try your own matchups

Change the team names in the `radar_chart()` call to compare any two teams in your dataset. Try pairing a tournament favourite with a surprise qualifier to see where the underdog might have an edge.

Getting a KeyError on a team name?

Team names must match exactly how they appear in your dataset (case-sensitive). Run rankings['team'].tolist() to see all valid team names. Common mismatches include "United States" vs "USA" or "Korea Republic" vs "South Korea".

Help me fix my radar chart team name error.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your notebook with all three new cells (10, 11, 12) and both PNG files are visible in your output.

ⓧ I'd like to double check the full code

Here is the full notebook code up to and including this step. Compare your cells against this:

# Cell 1: Setup and Imports
import pandas as pd
import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from itertools import islice

print("Libraries loaded successfully!")
print(f"NetworkX version: {nx.__version__}")
print(f"pandas version: {pd.__version__}")

# Cell 2: Load and Explore Data
df = pd.read_csv("path/to/fifa_world_cup_2026_dataset.csv")

print("Dataset shape:", df.shape)
print("\nColumn names:")
print(df.columns.tolist())
print("\nFirst few rows:")
df.head()

# Cell 3: Inspect Available Statistics
print("\nDataset info:")
df.info()
print("\nSample match data:")
df.iloc[0]

# Cell 4: Build Offensive Dominance Graph
G_offense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        goal_diff = home_goals - away_goals
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(home_team, away_team, weight=goal_diff)
    elif away_goals > home_goals:
        goal_diff = away_goals - home_goals
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += goal_diff
        else:
            G_offense.add_edge(away_team, home_team, weight=goal_diff)
    else:
        weight = 0.5
        if G_offense.has_edge(home_team, away_team):
            G_offense[home_team][away_team]['weight'] += weight
        else:
            G_offense.add_edge(home_team, away_team, weight=weight)
        if G_offense.has_edge(away_team, home_team):
            G_offense[away_team][home_team]['weight'] += weight
        else:
            G_offense.add_edge(away_team, home_team, weight=weight)

print(f"Offensive Graph: {G_offense.number_of_nodes()} nodes, {G_offense.number_of_edges()} edges")

# Cell 5: Build Territorial Dominance Graph
G_territory = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']

    home_possession = match.get('home_possession', 50)
    away_possession = match.get('away_possession', 50)

    possession_diff = abs(home_possession - away_possession)
    if possession_diff > 0:
        if home_possession > away_possession:
            if G_territory.has_edge(home_team, away_team):
                G_territory[home_team][away_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(home_team, away_team, weight=possession_diff)
        else:
            if G_territory.has_edge(away_team, home_team):
                G_territory[away_team][home_team]['weight'] += possession_diff
            else:
                G_territory.add_edge(away_team, home_team, weight=possession_diff)

print(f"Territorial Graph: {G_territory.number_of_nodes()} nodes, {G_territory.number_of_edges()} edges")

# Cell 6: Build Defensive Vulnerability Graph
G_defense = nx.DiGraph()

for _, match in df.iterrows():
    home_team = match['home_team']
    away_team = match['away_team']
    home_goals = match['home_team_score']
    away_goals = match['away_team_score']

    if home_goals > away_goals:
        if G_defense.has_edge(home_team, away_team):
            G_defense[home_team][away_team]['weight'] += home_goals
        else:
            G_defense.add_edge(home_team, away_team, weight=home_goals)
    elif away_goals > home_goals:
        if G_defense.has_edge(away_team, home_team):
            G_defense[away_team][home_team]['weight'] += away_goals
        else:
            G_defense.add_edge(away_team, home_team, weight=away_goals)
    else:
        if home_goals > 0:
            if G_defense.has_edge(home_team, away_team):
                G_defense[home_team][away_team]['weight'] += home_goals
            else:
                G_defense.add_edge(home_team, away_team, weight=home_goals)
            if G_defense.has_edge(away_team, home_team):
                G_defense[away_team][home_team]['weight'] += away_goals
            else:
                G_defense.add_edge(away_team, home_team, weight=away_goals)

print(f"Defensive Vulnerability Graph: {G_defense.number_of_nodes()} nodes, {G_defense.number_of_edges()} edges")

# Cell 7: Compute PageRank for Each Layer
pr_offense = nx.pagerank(G_offense, alpha=0.85, weight='weight')
pr_territory = nx.pagerank(G_territory, alpha=0.85, weight='weight')
pr_defense = nx.pagerank(G_defense, alpha=0.85, weight='weight')

print("Top 10 by Offensive PageRank:")
for team, score in sorted(pr_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

# Cell 8: Compute Betweenness and Eigenvector Centrality
bc_offense = nx.betweenness_centrality(G_offense, weight='weight')
ec_offense = nx.eigenvector_centrality(G_offense, max_iter=1000, weight='weight')

print("\nTop 10 by Betweenness Centrality (Gatekeeper Teams):")
for team, score in sorted(bc_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

print("\nTop 10 by Eigenvector Centrality:")
for team, score in sorted(ec_offense.items(), key=lambda x: x[1], reverse=True)[:10]:
    print(f"  {team}: {score:.4f}")

# Cell 9: Build Combined Rankings DataFrame
all_teams = list(G_offense.nodes())

rankings = pd.DataFrame({
    'team': all_teams,
    'offensive_pagerank': [pr_offense.get(t, 0) for t in all_teams],
    'territorial_pagerank': [pr_territory.get(t, 0) for t in all_teams],
    'defensive_vulnerability': [pr_defense.get(t, 0) for t in all_teams],
    'betweenness': [bc_offense.get(t, 0) for t in all_teams],
    'eigenvector': [ec_offense.get(t, 0) for t in all_teams],
})

for col in rankings.columns[1:]:
    col_min = rankings[col].min()
    col_max = rankings[col].max()
    if col_max > col_min:
        rankings[col + '_norm'] = (rankings[col] - col_min) / (col_max - col_min)
    else:
        rankings[col + '_norm'] = 0

rankings['composite_score'] = (
    rankings['offensive_pagerank_norm'] +
    rankings['territorial_pagerank_norm'] +
    rankings['eigenvector_norm'] -
    rankings['defensive_vulnerability_norm']
) / 4

rankings = rankings.sort_values('composite_score', ascending=False).reset_index(drop=True)
print("\nGraph-Based Power Rankings:")
print(rankings[['team', 'offensive_pagerank', 'territorial_pagerank',
                'defensive_vulnerability', 'composite_score']].head(15).to_string())

# Cell 10: Community Detection (Girvan-Newman)
communities_generator = nx.community.girvan_newman(G_offense)
top_level_communities = next(communities_generator)

community_list = sorted(top_level_communities, key=len, reverse=True)

print(f"\nDetected {len(community_list)} communities:")
for i, community in enumerate(community_list):
    print(f"  Tier {i+1} ({len(community)} teams): {sorted(community)}")

team_community = {}
for i, community in enumerate(community_list):
    for team in community:
        team_community[team] = i

# Cell 11: Visualize the Offensive Dominance Network
plt.figure(figsize=(14, 10))

pos = nx.spring_layout(G_offense, seed=42, k=2)

node_sizes = [pr_offense.get(node, 0) * 8000 + 200 for node in G_offense.nodes()]

colors = plt.cm.Set3(np.linspace(0, 1, len(community_list)))
node_colors = [colors[team_community.get(node, 0)] for node in G_offense.nodes()]

edge_weights = [G_offense[u][v]['weight'] for u, v in G_offense.edges()]
max_weight = max(edge_weights) if edge_weights else 1
edge_widths = [w / max_weight * 3 for w in edge_weights]

nx.draw_networkx_nodes(G_offense, pos, node_size=node_sizes,
                       node_color=node_colors, alpha=0.8)
nx.draw_networkx_edges(G_offense, pos, width=edge_widths,
                       alpha=0.3, edge_color='gray',
                       arrowsize=10, arrowstyle='->')
nx.draw_networkx_labels(G_offense, pos, font_size=7, font_weight='bold')

legend_patches = [mpatches.Patch(color=colors[i], label=f'Tier {i+1}')
                  for i in range(min(len(community_list), 5))]
plt.legend(handles=legend_patches, loc='upper left', fontsize=10)

plt.title("FIFA World Cup 2026 - Offensive Dominance Network\n(Node size = PageRank, Color = Community Tier)",
          fontsize=13)
plt.axis('off')
plt.tight_layout()
plt.savefig('offensive_dominance_network.png', dpi=150, bbox_inches='tight')
plt.show()

# Cell 12: Radar Chart - Team Profile Comparison
def radar_chart(team1, team2, rankings_df):
    categories = ['Offensive\nPageRank', 'Territorial\nPageRank',
                  'Defensive\nVulnerability', 'Betweenness\n(Gatekeeper)',
                  'Eigenvector\nCentrality']
    norm_cols = ['offensive_pagerank_norm', 'territorial_pagerank_norm',
                 'defensive_vulnerability_norm', 'betweenness_norm',
                 'eigenvector_norm']

    team1_data = rankings_df[rankings_df['team'] == team1][norm_cols].values.flatten()
    team2_data = rankings_df[rankings_df['team'] == team2][norm_cols].values.flatten()

    angles = np.linspace(0, 2 * np.pi, len(categories), endpoint=False).tolist()
    angles += angles[:1]

    team1_values = team1_data.tolist() + [team1_data[0]]
    team2_values = team2_data.tolist() + [team2_data[0]]

    fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
    ax.plot(angles, team1_values, 'o-', linewidth=2, label=team1, color='#1f77b4')
    ax.fill(angles, team1_values, alpha=0.15, color='#1f77b4')
    ax.plot(angles, team2_values, 'o-', linewidth=2, label=team2, color='#ff7f0e')
    ax.fill(angles, team2_values, alpha=0.15, color='#ff7f0e')

    ax.set_xticks(angles[:-1])
    ax.set_xticklabels(categories, fontsize=10)
    ax.set_ylim(0, 1)
    ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1), fontsize=11)
    ax.set_title(f"Team Profile: {team1} vs {team2}", fontsize=13, pad=20)

    plt.tight_layout()
    plt.savefig(f'radar_{team1}_vs_{team2}.png', dpi=150, bbox_inches='tight')
    plt.show()

radar_chart('Spain', 'Ecuador', rankings)

You now have a complete team intelligence system. Your network visualization reveals the hidden power structure of the tournament, and your radar chart lets you drill into any head-to-head matchup. Next up, you'll take this further in the Secret Mission by building a Weakness Exploiter that identifies which opponents each team should fear most.

Secret mission

Build the Weakness Exploiter

Your graph rankings reveal who is strong overall. But coaches don't need overall rankings. They need to know: who should my team fear the most, and why? In this mission, you'll combine graph traversal with custom metric design to build a tool that answers that question using structural threats hidden in the graph topology.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Jupyter Notebook (world_cup_graph_analysis.ipynb).
  • Generated PNG visualizations (offensive_dominance_network.png, radar_Spain_vs_Ecuador.png, threat_network_Spain.png).
  • Downloaded Kaggle dataset CSV file.
  • Installed Python packages (networkx, pandas, matplotlib).
  • Kaggle account (free).

Keep everything running

No action needed. Choose this if you plan to continue experimenting with the graph analysis or want to add more match data as the tournament progresses.

  • Your notebook and visualizations stay in place for further exploration.
  • The installed Python packages remain available for other data science projects.
  • Your Kaggle account stays active for downloading updated datasets.

Pause - I'll come back to this later

Shut down any running Jupyter processes to free up memory. Your files stay on disk.

  • In the terminal where Jupyter is running, press Ctrl+C to stop the Jupyter server.
  • Your notebook, dataset, and PNG files remain saved and ready for when you return.

Delete - I don't want to use this again

Remove all project files and uninstall the packages you added.

  • Stop the Jupyter server by pressing Ctrl+C in its terminal.
  • Delete the notebook and generated images by running:
rm world_cup_graph_analysis.ipynb offensive_dominance_network.png radar_Spain_vs_Ecuador.png threat_network_Spain.png
  • Delete the downloaded Kaggle dataset CSV file from wherever you saved it.
  • Uninstall the Python packages you added for this project by running:
pip uninstall networkx pandas matplotlib -y
  • To delete your Kaggle account, go to kaggle.com, click your profile icon, select Settings, scroll to the bottom of the Account section, and click Delete Account.

Nice Work!

Nice Work!

Nice work! You just built a complete graph-based team intelligence system that reveals hidden power structures in the FIFA World Cup 2026 using the same algorithm Google uses to rank web pages.

You've learned how to:

  • Model real-world sports data as multi-layer directed graphs with weighted edges representing offensive dominance, territorial control, and defensive vulnerability.
  • Apply PageRank, betweenness centrality, and eigenvector centrality to compute transitive power rankings that surface insights invisible to traditional statistics.
  • Use Girvan-Newman community detection to discover natural team tiers and produce publication-ready network visualizations with radar charts comparing teams across multiple dimensions.
  • Secret Mission: Built a Weakness Exploiter tool that identifies structural threats to any team based on graph topology, combining custom metric design with ego graph visualization.

Ready to quiz yourself?