Virtual Sports Cross-League Analyzer

Test if cross-league patterns in virtual sports data are real or noise.

Introduction

30 Second Summary

You have been watching virtual sports matches for weeks, tracking patterns across leagues. But the nagging question remains: are the patterns you are spotting actually real, or is your brain finding meaning in random noise?

In this project, you will build a statistical analysis notebook in Google Colab that connects to your existing match data and mathematically tests whether cross-league patterns are genuine signals. You will walk away with a tool that gives you data-backed confidence scores instead of guesses.

What You'll Build

Picture running a single cell and watching your notebook pull in fresh data from your Google Sheets spreadsheet, crunch the numbers, and output something like "League X and League Y show a statistically significant correlation at 38% confidence, compared to a 28% baseline."

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

  • A data pipeline that pulls live data from your Google Sheet, flags corrupted rows, and delivers a clean DataFrame ready for analysis in seconds.
  • Statistical verdicts on your cross-league hypothesis, with correlation heatmaps and p-values that tell you exactly which league pairs genuinely influence each other within a cycle.
  • A pattern confidence scorer you can run mid-cycle that shows what historically followed when similar results occurred, with exact confidence percentages compared to baseline.
  • Secret Mission: Build a rolling-window stability test that reveals whether your discovered patterns are persistent or decaying over time.

Are there any prerequisites?

You need a Google account and an existing Google Sheet with virtual sports match data (around 1,250 rows). If you do not have match data yet, you can create sample data to follow along.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Google Colab and Connect to Your Sheet

Your Google Sheet has been collecting virtual sports match data row by row. But raw rows in a spreadsheet can't tell you whether the cross-league patterns you've been noticing are statistically real or just noise.

Google Colab gives you a free Python environment that connects directly to your Google Sheet with zero installation. In this step, you'll create a notebook, authenticate with your Google account, and load your match data into a pandas DataFrame ready for analysis.

In this step, get ready to:
  • Create a new notebook in Google Colab.
  • Install gspread and authenticate with Google.
  • Load your spreadsheet data into a pandas DataFrame.
Create a new notebook in Google Colab

Google Colab is a browser-based Python notebook. You don't need to install anything on your computer.

  • Open your browser and navigate to colab.research.google.com.
  • Sign in with the Google account that owns your match data spreadsheet.
  • Click New Notebook in the bottom-right of the popup.
  • Click the default title (Untitled0.ipynb) at the top-left of the page.
  • Rename it to Virtual Sports Pattern Analyzer.

You now have an empty notebook with one code cell ready for input.

Don't see the New Notebook button?

If a file picker opens instead of the welcome popup, click File in the top menu bar, then select New notebook.

help me create a new Google Colab notebook

Install gspread and authenticate with Google

Your notebook needs the gspread library to talk to Google Sheets. It also needs permission to access your Google account's spreadsheets.

  • Paste the following code into the first code cell and run it by pressing Shift+Enter:
# Install gspread (may already be available in Colab)
!pip install --upgrade gspread --quiet

from google.colab import auth
import gspread
from google.auth import default
import pandas as pd
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
import seaborn as sns

# Authenticate with Google
auth.authenticate_user()
creds, _ = default()
gc = gspread.authorize(creds)

What does this code do?

  • !pip install --upgrade gspread --quiet installs the latest version of the gspread library, which lets Python read and write Google Sheets.
  • The import lines bring in all the tools you'll use throughout this project: pandas for data manipulation, numpy for math, scipy for statistical tests, and matplotlib/seaborn for charts.
  • auth.authenticate_user() triggers a popup window asking you to grant Colab permission to access your Google Sheets.
  • gc = gspread.authorize(creds) creates your authorized client that can open any spreadsheet your Google account has access to.
  • A popup window will appear asking you to select your Google account. Select the account that owns your match data spreadsheet.
  • Click Allow to grant Colab permission to access your Google Sheets and Drive.

The popup closes automatically once you grant access. The cell finishes running with no error output, which means authentication succeeded.

Seeing a permission error or the popup didn't appear?

Make sure you are signed into the same Google account in both Colab and your browser. If the popup was blocked, check your browser's popup blocker settings and allow popups from colab.research.google.com.

help me fix the Google Colab authentication popup

Load your spreadsheet data into a DataFrame

Now that Colab is authenticated, you can pull your match data directly from Google Sheets into a pandas DataFrame for analysis.

  • Click the + Code button at the top of the notebook to create a new code cell below the first one.
  • Open your Google Sheet in another browser tab and copy the full URL from the address bar.
  • Paste the following code into the new cell, replacing the placeholder URL with your actual Google Sheet URL:
# Connect to your spreadsheet - replace with your sheet URL
SHEET_URL = "https://docs.google.com/spreadsheets/d/your-spreadsheet-id-here/edit"
spreadsheet = gc.open_by_url(SHEET_URL)
worksheet = spreadsheet.sheet1

# Load data into pandas DataFrame
data = worksheet.get_all_records()
df = pd.DataFrame(data)

print(f"Loaded {len(df)} rows and {len(df.columns)} columns")
print(f"Columns: {list(df.columns)}")
df.head()

What does this code do?

  • SHEET_URL stores the link to your specific spreadsheet. The gc.open_by_url() method uses your authenticated client to open it.
  • worksheet.get_all_records() reads every row from the first sheet tab and returns them as a list of dictionaries, one per row. The column headers become the dictionary keys.
  • pd.DataFrame(data) converts that list into a structured table you can filter, group, and analyze.
  • df.head() displays the first 5 rows so you can confirm the data looks correct.

Before you run this cell, what do you expect to see printed? Think about how many rows your sheet has.

  • Run the cell by pressing Shift+Enter.

You should see output like Loaded 1250 rows and 15 columns (your exact numbers will match your sheet's size) followed by a table showing the first 5 rows of your data.

  • Confirm the column names include Timestamp, Match_ID, League, Home, Away, score columns, and Status.

Seeing a SpreadsheetNotFound error?

Double-check that the URL you pasted is the full URL from your browser's address bar, including the /edit at the end. Make sure the spreadsheet belongs to the same Google account you authenticated with.

If you see a PermissionError, your sheet may not be accessible to the authenticated account. Open the sheet and check that your signed-in account is the owner or has edit access.

help me fix my gspread connection to Google Sheets

✔️ Awesome, I've got everything!

Great. Make sure both cells are saved in your notebook. You now have a live connection between Colab and your match data.

ⓧ I'd like to double check the full code

Here is the complete code across your two cells. Confirm yours matches:

Cell 1:

!pip install --upgrade gspread --quiet

from google.colab import auth
import gspread
from google.auth import default
import pandas as pd
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
import seaborn as sns

auth.authenticate_user()
creds, _ = default()
gc = gspread.authorize(creds)

Cell 2:

SHEET_URL = "https://docs.google.com/spreadsheets/d/your-spreadsheet-id-here/edit"
spreadsheet = gc.open_by_url(SHEET_URL)
worksheet = spreadsheet.sheet1

data = worksheet.get_all_records()
df = pd.DataFrame(data)

print(f"Loaded {len(df)} rows and {len(df.columns)} columns")
print(f"Columns: {list(df.columns)}")
df.head()

Your data is now in Python's hands. Next up, you'll clean it and handle the corrupted rows from the n8n ID change so every number you analyze from here is trustworthy.

Clean the Data and Handle Corrupted Rows

Your data is loaded, but it is not ready for analysis yet. Your n8n automation had an ID change issue that introduced corrupted rows into the sheet.

If you run statistical tests on dirty data, every conclusion will be unreliable. Cleaning first means you can trust everything that follows. In this step, you will build a cleaning pipeline that detects problems, removes bad rows, and adds the derived columns your analysis needs.

In this step, get ready to:
  • Standardize column names and convert score columns to numeric types.
  • Detect and remove corrupted rows (missing scores, impossible values, duplicates).
  • Add derived columns for analysis: total goals, match result, over/under, and cycle grouping.
Standardize columns and convert data types

Before you can detect bad data, the columns need consistent formatting. Trailing whitespace in league names or lowercase status values would cause your detection logic to miss corrupted rows entirely.

  • Create a new code cell below your authentication cell by clicking + Code in the Colab toolbar.
  • Add the standardization and type conversion code by pasting the following:
# Print original row count for comparison
print(f"Original rows: {len(df)}")

# Strip whitespace from all column names
df.columns = df.columns.str.strip()

# Standardize string columns (strip whitespace, normalize casing)
df["League"] = df["League"].str.strip()
df["Status"] = df["Status"].str.strip().str.upper()
df["Home"] = df["Home"].str.strip()
df["Away"] = df["Away"].str.strip()

# Convert score columns to numeric (non-numeric values become NaN)
score_cols = ["HT_H", "HT_A", "FT_H", "FT_A"]
for col in score_cols:
    df[col] = pd.to_numeric(df[col], errors="coerce")

# Convert odds columns to numeric
odds_cols = ["1 (Home Win Odds)", "X (Draw Odds)", "2 (Away Win Odds)", "OU_Line"]
for col in odds_cols:
    if col in df.columns:
        df[col] = pd.to_numeric(df[col], errors="coerce")

What does this code do?

This block normalizes your raw data so detection logic works reliably.

  • df.columns.str.strip() removes hidden whitespace from column headers that would break column lookups.
  • str.upper() on the Status column ensures all completed matches read "FT" regardless of how n8n wrote them.
  • pd.to_numeric() with errors="coerce" converts scores to numbers and turns any text corruption into NaN, which you can detect later.
  • Run the cell by pressing Shift+Enter.

You should see a single line printed: Original rows: 1250 (or whatever count your sheet has).

Seeing a KeyError?

If you get a KeyError on a column name like League or Status, your Google Sheet column headers may be named differently. Run print(df.columns.tolist()) in a temporary cell to see the exact names and adjust accordingly.

Help me fix a KeyError in my data cleaning cell.

Flag and remove corrupted rows

Now that your columns have clean types, you can programmatically identify the rows that should not be trusted. SportyBet caps virtual football at 6 goals per match, so anything above that is data corruption.

  • Add the corruption detection logic below your existing code in the same cell:
# Build a boolean mask to flag all corrupted rows
corrupted_mask = pd.Series(False, index=df.index)

# Rule 1: Status is FT but scores are missing (data loss during n8n sync)
ft_missing_scores = (df["Status"] == "FT") & (df[score_cols].isnull().any(axis=1))
corrupted_mask = corrupted_mask | ft_missing_scores

# Rule 2: Impossible scores (negative or above SportyBet's max of 6)
for col in score_cols:
    impossible = (df[col] < 0) | (df[col] > 6)
    corrupted_mask = corrupted_mask | impossible.fillna(False)

# Rule 3: Duplicate Match_IDs from the n8n ID change (keep first occurrence)
duplicate_ids = df.duplicated(subset=["Match_ID"], keep="first")
corrupted_mask = corrupted_mask | duplicate_ids

# Print what was found
print(f"Rows flagged as corrupted: {corrupted_mask.sum()}")
print(f"  - FT with missing scores: {ft_missing_scores.sum()}")
print(f"  - Impossible scores: {((df[score_cols] < 0) | (df[score_cols] > 6)).any(axis=1).fillna(False).sum()}")
print(f"  - Duplicate Match_IDs: {duplicate_ids.sum()}")

# Remove corrupted rows and keep only completed matches
df_clean = df[~corrupted_mask].copy()
df_clean = df_clean[df_clean["Status"] == "FT"].copy()

What does this code do?

This builds a boolean mask that is True for every row that fails at least one quality check.

  • Rule 1 catches matches that n8n logged as finished but lost their score data during the ID change.
  • Rule 2 catches impossible scores. SportyBet caps virtual football at 6 goals per match, so anything negative or above 6 is corruption.
  • Rule 3 catches duplicate entries from n8n re-processing the same match after the ID format changed.
  • The final two lines remove all flagged rows, then keep only matches with Status "FT" (full time) to exclude any in-progress or cancelled matches.
  • Run the cell again with Shift+Enter.

Below the original row count, you now see a breakdown of how many rows each rule caught. The numbers will depend on your specific data, but you should see at least a few flagged rows if your n8n pipeline had the ID change issue.

Zero corrupted rows flagged?

If all three categories show 0, your data may already be clean. This is fine. The cleaning step still converts types and standardizes values, which the analysis steps require.

Help me understand why my corruption detection found zero rows.

Add derived columns for analysis

Your clean DataFrame now has only trustworthy rows. The analysis steps ahead need several computed values that do not exist in the raw data: total goals, match result category, and a cycle grouping column.

  • Add the derived columns below your existing code in the same cell:
# Total goals scored in the match (FT scores include HT)
df_clean["Total_Goals"] = df_clean["FT_H"] + df_clean["FT_A"]

# Match result: Home win, Draw, or Away win
conditions = [
    df_clean["FT_H"] > df_clean["FT_A"],
    df_clean["FT_H"] == df_clean["FT_A"],
    df_clean["FT_H"] < df_clean["FT_A"]
]
choices = ["H", "D", "A"]
df_clean["Result"] = np.select(conditions, choices, default="Unknown")

# Over/Under actual result vs the OU line
df_clean["Over_Under_Actual"] = np.where(
    df_clean["Total_Goals"] > df_clean["OU_Line"], "Over", "Under"
)

# Convert Timestamp to datetime and drop unparseable rows
df_clean["Timestamp"] = pd.to_datetime(df_clean["Timestamp"], errors="coerce")
df_clean = df_clean.dropna(subset=["Timestamp"])

# Group matches into hourly cycles
df_clean["Cycle_ID"] = df_clean["Timestamp"].dt.floor("h")

# Print final summary
print(f"\nClean rows remaining: {len(df_clean)}")
print(f"Unique leagues: {df_clean['League'].nunique()} - {df_clean['League'].unique()}")
print(f"Unique cycles: {df_clean['Cycle_ID'].nunique()}")
print(f"Date range: {df_clean['Timestamp'].min()} to {df_clean['Timestamp'].max()}")

What do these derived columns do?

  • Total_Goals sums the final scores. FT scores already include half-time goals, so you only need FT_H + FT_A.
  • Result categorizes each match as H (home win), D (draw), or A (away win) based on who scored more.
  • Over_Under_Actual compares total goals against the betting line to determine if the match went over or under.
  • Cycle_ID floors each timestamp to the hour, grouping matches that ran in the same hourly cycle together. This is the key column for testing cross-league interactions.

Before you run this, what do you expect the clean row count to be relative to your original count?

  • Run the full cell with Shift+Enter.

The final output shows your clean row count, the leagues in your data, how many hourly cycles you have, and the date range covered. You should see fewer rows than the original count, confirming that corrupted data was removed.

Seeing zero clean rows remaining?

This usually means the Status column values do not match "FT" after uppercasing. Run df["Status"].unique() in a temporary cell to see what values are present. If your sheet uses a different marker for completed matches, update the filter line to match.

Help me debug why my cleaned DataFrame has zero rows.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your notebook (Ctrl+S or Cmd+S).

ⓧ I'd like to double check the full code

Your Cell 2 should contain all three sections you built above, in this order:

  • Standardization block: prints original row count, strips column names and string columns, converts score and odds columns to numeric.
  • Corruption detection block: builds corrupted_mask with three rules, prints the breakdown, creates df_clean by filtering out bad rows and keeping only FT matches.
  • Derived columns block: adds Total_Goals, Result, Over_Under_Actual, and Cycle_ID, then prints the final summary with clean row count, leagues, cycles, and date range.

Scroll up and confirm each section is present in your cell, in order. If anything is missing, copy it from the relevant chunk above.

Your data is clean, typed correctly, and enriched with the columns your analysis needs. Next up, you will visualize what each league looks like on its own before testing whether they interact with each other.

Visualize Per-League Patterns

In the last step you cleaned over a thousand rows of match data and structured them for analysis. Now you have a trustworthy dataset to work with.

Before testing whether leagues influence each other, you need to understand what each league looks like on its own. This baseline reveals which leagues are high-scoring and which are defensive. It also shows whether any single league has obvious biases built into the simulation.

In this step, get ready to:
  • Compute per-league win rates, average goals, and over/under frequencies.
  • Create grouped bar charts comparing league outcomes side by side.
  • Plot goal distribution histograms to see where each league falls relative to the 2.5 over/under line.
Compute league frequencies and create bar charts

Every league in SportyBet Virtual Football uses its own RNG parameters. Some leagues might produce more home wins. Others might generate higher-scoring matches. Seeing this in a chart makes the differences jump out immediately.

  • Create a new code cell below Cell 2 in your notebook.
  • Paste the following code to compute league-level result frequencies and plot them as grouped bar charts:
fig, axes = plt.subplots(1, 2, figsize=(16, 6))

# Chart 1: Win/Draw/Loss rates per league
league_results = df_clean.groupby("League")["Result"].value_counts(normalize=True).unstack(fill_value=0)
league_results = league_results.reindex(columns=["H", "D", "A"], fill_value=0)
league_results.columns = ["Home Win", "Draw", "Away Win"]
league_results.plot(kind="bar", ax=axes[0], color=["#2ecc71", "#f39c12", "#e74c3c"])
axes[0].set_title("Win/Draw/Loss Rates by League")
axes[0].set_ylabel("Proportion")
axes[0].set_xlabel("")
axes[0].legend(loc="upper right")
axes[0].set_ylim(0, 0.6)
axes[0].tick_params(axis="x", rotation=45)

# Chart 2: Average goals per league
league_goals = df_clean.groupby("League")["Total_Goals"].mean().sort_values(ascending=False)
league_goals.plot(kind="bar", ax=axes[1], color="#3498db")
axes[1].set_title("Average Total Goals by League")
axes[1].set_ylabel("Average Goals")
axes[1].set_xlabel("")
axes[1].axhline(y=2.5, color="red", linestyle="--", label="OU Line (2.5)")
axes[1].legend()
axes[1].tick_params(axis="x", rotation=45)

plt.tight_layout()
plt.show()

What does this code do?

  • The first chart groups matches by league and calculates what percentage ended in a Home Win, Draw, or Away Win. This tells you if any league is biased toward a particular outcome.
  • The second chart shows average total goals per league, sorted highest to lowest. The red dashed line at 2.5 marks the standard over/under threshold.
  • normalize=True converts raw counts into proportions so leagues with different match totals are still comparable.
  • Run the cell by pressing the play button or hitting Shift+Enter.

You should see two bar charts side by side. The left chart shows Home Win, Draw, and Away Win proportions for each league. The right chart shows average goals with a red dashed line at 2.5.

Charts not displaying?

  • If you see a NameError for df_clean, make sure you ran Cell 1 and Cell 2 first. Colab cells must run in order.
  • If the bars are all zero height, check that your Result column contains values H, D, and A. Go back to Cell 2 and confirm the derived columns were created correctly.

Why aren't my per-league bar charts rendering in Colab?

Plot goal distributions and print a summary table

Bar charts show averages, but averages can hide important details. A league averaging 2.4 goals might have most matches landing at exactly 2 or 3 goals. A histogram reveals the full distribution so you can see how spread out or clustered the goals are.

  • Add the following code below your existing code in the same cell to plot goal distribution histograms for each league:
# Goal distribution histograms per league
leagues = df_clean["League"].unique()
fig, axes = plt.subplots(1, len(leagues), figsize=(4 * len(leagues), 4), sharey=True)
if len(leagues) == 1:
    axes = [axes]

for i, league in enumerate(leagues):
    league_data = df_clean[df_clean["League"] == league]["Total_Goals"]
    axes[i].hist(league_data, bins=range(0, 8), color="#3498db", edgecolor="black", density=True)
    axes[i].axvline(x=2.5, color="red", linestyle="--", linewidth=2)
    axes[i].set_title(league)
    axes[i].set_xlabel("Total Goals")
    if i == 0:
        axes[i].set_ylabel("Density")

plt.suptitle("Goal Distribution by League (red line = 2.5 OU threshold)")
plt.tight_layout()
plt.show()

What does this code do?

  • It creates one histogram per league, placed side by side for visual comparison.
  • density=True normalizes the bars so each histogram shows proportions. This makes leagues with different match counts comparable.
  • The red vertical line at 2.5 marks the over/under threshold. Bars to the right of that line represent "Over" matches.
  • Run the cell. You should now see the two bar charts from before plus a row of histograms showing goal distributions for each league.

Look at where the bulk of each histogram sits relative to the red 2.5 line. If most bars cluster to the left, that league tends to go Under. If they spread to the right, it favours Over.

  • Now add this final section below your histograms code to print a numeric summary table:
# Print numeric summary table
print("\n--- League Summary Table ---")
summary = df_clean.groupby("League").agg(
    Matches=("Match_ID", "count"),
    Avg_Goals=("Total_Goals", "mean"),
    Home_Win_Pct=("Result", lambda x: (x == "H").mean()),
    Draw_Pct=("Result", lambda x: (x == "D").mean()),
    Away_Win_Pct=("Result", lambda x: (x == "A").mean()),
    Over_Pct=("Over_Under_Actual", lambda x: (x == "Over").mean()),
).round(3)
print(summary.to_string())

What does this code do?

  • It aggregates your cleaned data into one row per league with key metrics: total matches, average goals, home win percentage, draw percentage, away win percentage, and over percentage.
  • .round(3) keeps the numbers readable at three decimal places.

Before you run the full cell, what do you expect the home win percentage to be for most leagues? Higher than away win, or roughly even?

  • Run the cell. Below the charts you should see a printed table with columns for Matches, Avg_Goals, Home_Win_Pct, Draw_Pct, Away_Win_Pct, and Over_Pct for each league.

The numbers in this table should roughly match what you have observed from months of watching these leagues. If a league that always felt high-scoring shows a higher Avg_Goals than the others, your intuition is confirmed by the data.

Seeing a KeyError or empty table?

  • A KeyError on Over_Under_Actual means Cell 2 did not create that column. Re-run Cell 2 and check for errors in the derived columns section.
  • If the table shows 0 matches for a league, your League column may have trailing whitespace that the cleaning did not catch. Add df_clean["League"].unique() to a temporary cell and look for duplicates like "England" vs "England ".

Help me debug my per-league summary table in Colab.

✔️ Awesome, I've got everything!

Great. Make sure your Cell 3 is saved. You should have the bar charts, histograms, and summary table all rendering from one cell.

ⓧ I'd like to double check the full code

Scroll through your Cell 3 and confirm these three sections appear in order:

  • The bar charts section starting with fig, axes = plt.subplots(1, 2, figsize=(16, 6)) and ending with plt.show().
  • The histograms section starting with leagues = df_clean["League"].unique() and ending with the second plt.show().
  • The summary table section starting with print("\n--- League Summary Table ---") and ending with print(summary.to_string()).

You now have a clear picture of how each league behaves independently. Some leagues favour home wins. Some produce more goals. These baselines are the foundation for the next step, where you test whether these leagues actually influence each other within a cycle.

Test Cross-League Correlations

You now know what each league looks like in isolation. The real question is different: do leagues influence each other within the same hourly cycle?

This step tests your core hypothesis mathematically. You will use Pearson correlation to measure whether goal totals move together across leagues, and chi-square tests to check whether categorical outcomes (Home Win, Draw, Away Win) are truly independent between league pairs.

In this step, get ready to:
  • Pivot your match data into a cycle-level summary matrix where each row is one hourly cycle.
  • Compute Pearson correlations and chi-square independence tests between all league pairs.
  • Visualize the results as a heatmap and print a plain-English summary of which correlations are statistically significant.
Pivot data into a cycle-level matrix and compute correlations

To test cross-league interactions, you need to reshape the data. Instead of one row per match, you need one row per cycle with each league's total goals as a column.

  • Create a new code cell below Cell 3 in your notebook.
  • Pivot the data and compute Pearson correlations by pasting this code:
# Pivot data: one row per cycle, columns are league-level metrics
cycle_goals = df_clean.pivot_table(
    index="Cycle_ID",
    columns="League",
    values="Total_Goals",
    aggfunc="sum"
).dropna()

print(f"Complete cycles for analysis: {len(cycle_goals)}")
print(f"Leagues in analysis: {list(cycle_goals.columns)}")

# Pearson correlation between league goal totals per cycle
corr_matrix = cycle_goals.corr()
n_cycles = len(cycle_goals)

# Compute p-values for each correlation
p_matrix = pd.DataFrame(
    np.zeros((len(corr_matrix), len(corr_matrix))),
    index=corr_matrix.index,
    columns=corr_matrix.columns
)

for i, col1 in enumerate(cycle_goals.columns):
    for j, col2 in enumerate(cycle_goals.columns):
        if i != j:
            r, p = stats.pearsonr(cycle_goals[col1], cycle_goals[col2])
            p_matrix.loc[col1, col2] = p
        else:
            p_matrix.loc[col1, col2] = 1.0

What does this code do?

The pivot_table reshapes your data so each row is one hourly cycle and each column is a league's total goals for that cycle. The .dropna() keeps only cycles where all leagues have data.

stats.pearsonr() returns a correlation coefficient (how strongly two leagues' goals move together) and a p-value (the probability this correlation happened by chance). A p-value below 0.05 means the correlation is statistically significant.

  • Run the cell by pressing the play button or Shift+Enter.

You should see output like "Complete cycles for analysis: 250" and the list of league names included in the analysis.

Seeing 0 complete cycles?

This means every cycle has at least one league with no matches. Check that your League column names are consistent (no trailing spaces or casing differences). The cleaning step should have handled this, but run df_clean["League"].unique() to inspect.

Help me debug why my pivot table has 0 complete cycles after dropna.

Visualize the correlation heatmap

Numbers alone are hard to interpret across many league pairs. A heatmap makes it immediately obvious which pairs are correlated and which are not.

  • Add this visualization code below the correlation computation in the same cell:
# Create annotation labels with r and p values
fig, ax = plt.subplots(figsize=(10, 8))

annot_labels = pd.DataFrame("", index=corr_matrix.index, columns=corr_matrix.columns)
for i in corr_matrix.index:
    for j in corr_matrix.columns:
        r_val = corr_matrix.loc[i, j]
        p_val = p_matrix.loc[i, j]
        sig = "*" if p_val < 0.05 else ""
        annot_labels.loc[i, j] = f"{r_val:.2f}{sig}\n(p={p_val:.3f})"

sns.heatmap(
    corr_matrix,
    annot=annot_labels,
    fmt="",
    cmap="RdYlGn",
    center=0,
    vmin=-1,
    vmax=1,
    ax=ax,
    square=True
)
ax.set_title(f"Cross-League Goal Correlation (n={n_cycles} cycles)\n* = statistically significant at p<0.05")
plt.tight_layout()
plt.show()

What does this heatmap show?

Each cell shows the correlation coefficient between two leagues' goal totals per cycle. Values near +1 mean both leagues score high (or low) in the same cycles. Values near -1 mean one league scores high when the other scores low. Values near 0 mean no relationship.

The asterisk (*) marks pairs where p < 0.05. Those are the correlations strong enough to be unlikely by random chance alone.

  • Run the cell again.

You should see a color-coded heatmap with green cells (positive correlation), red cells (negative correlation), and each cell annotated with the r value and p-value.

Heatmap not rendering?

Make sure you added the visualization code below the first chunk in the same cell, not in a new cell. The heatmap depends on corr_matrix and p_matrix which are computed above it.

Help me figure out why my seaborn heatmap is not displaying in Colab.

Run chi-square independence tests

Pearson correlation tests whether goal totals move together. But you also want to know if the categorical outcomes (Home Win, Draw, Away Win) are independent between leagues. A chi-square test answers that directly.

  • Add the chi-square test code below the heatmap visualization in the same cell:
# Chi-square test for result independence between league pairs
print("\n--- Chi-Square Independence Tests (Results: H/D/A) ---")
cycle_results = df_clean.pivot_table(
    index="Cycle_ID",
    columns="League",
    values="Result",
    aggfunc="first"
).dropna()

significant_pairs = []
league_list = list(cycle_results.columns)

for i in range(len(league_list)):
    for j in range(i + 1, len(league_list)):
        league_a = league_list[i]
        league_b = league_list[j]
        contingency = pd.crosstab(cycle_results[league_a], cycle_results[league_b])
        chi2, p_val, dof, expected = stats.chi2_contingency(contingency)
        sig_marker = "SIGNIFICANT" if p_val < 0.05 else "not significant"
        print(f"  {league_a} vs {league_b}: chi2={chi2:.2f}, p={p_val:.4f} [{sig_marker}]")
        if p_val < 0.05:
            significant_pairs.append((league_a, league_b, chi2, p_val))

print(f"\n--- Summary ---")
if significant_pairs:
    print(f"Found {len(significant_pairs)} statistically significant cross-league relationship(s):")
    for pair in significant_pairs:
        print(f"  {pair[0]} <-> {pair[1]} (chi2={pair[2]:.2f}, p={pair[3]:.4f})")
else:
    print("No statistically significant cross-league correlations found at the 0.05 level.")
    print("This means the leagues appear to operate independently within each cycle.")

What does the chi-square test check?

stats.chi2_contingency() builds a contingency table (e.g., how often England = Home Win AND Germany = Away Win) and tests whether the observed frequencies differ from what you would expect if the two leagues were completely independent.

If p < 0.05, the result pattern in one league is statistically linked to the result pattern in the other. If p > 0.05, the leagues appear to roll their outcomes independently.

Before you run the full cell, take a moment to predict: do you think the chi-square tests will find significant cross-league dependencies, or will the leagues turn out to be independent?

  • Run the full cell.

You will see the heatmap followed by the chi-square results printed for each league pair. The final summary either names the significant pairs or confirms that no significant cross-league correlations were found at the 0.05 level. Both outcomes are valid and useful.

What if nothing is significant?

Finding no significant correlations is still a meaningful result. It tells you the leagues are statistically independent, which means your brain was interpreting random noise as patterns. That saves you from acting on false signals.

Getting a ValueError from chi2_contingency?

This usually means a contingency table has a row or column of all zeros (one league never produced a particular result in the filtered cycles). Check that all three results (H, D, A) appear for each league by running df_clean.groupby("League")["Result"].value_counts().

Help me fix a ValueError from chi2_contingency in my cross-league analysis.

✔️ Awesome, I've got everything!

Great. Confirm you have saved your notebook with all four cells intact.

ⓧ I'd like to double check the full code

Cell 4 should contain these three sections in order. Scroll up and confirm each one is present:

  • The pivot table (cycle_goals) and Pearson correlation computation (corr_matrix, p_matrix).
  • The seaborn heatmap visualization with annotated r and p-values.
  • The chi-square independence tests with the summary block that prints significant pairs or the "no significant correlations" message.

You now have a mathematical answer to the question you started with. Whether the correlations are real or noise, you have evidence instead of a hunch. Next, you will turn those findings into an actionable tool that makes predictions in real time.

Build the Pattern Confidence Scorer

Your correlation analysis proved whether cross-league patterns exist in the data. Finding a statistically significant relationship is valuable, but it does not tell you what to do right now when a cycle is underway.

In this step, you will build a pattern confidence scorer. It takes partial cycle results (which leagues have finished and what happened) and checks how often that same pattern occurred in your historical data. Then it outputs what historically followed, with a confidence percentage you can act on.

In this step, get ready to:
  • Write a function that filters historical cycles by known results from completed leagues.
  • Compute outcome distributions and confidence uplift for target leagues.
  • Format predictions as a readable report and test with sample data.
Write the cycle-matching logic

The scorer needs to answer one question: given what just happened in some leagues, what happened in the target league when this same pattern occurred in the past?

  • Create a new code cell below your correlation analysis cell (Cell 4).
  • Start building the score_pattern function by adding this code:
def score_pattern(df_clean, known_results, target_league):
    """
    Given known results from some leagues in the current cycle,
    predict the outcome for a target league based on historical patterns.

    Parameters:
    - df_clean: the cleaned DataFrame
    - known_results: dict like {"England": "H", "Germany": "A"}
    - target_league: string like "Italy"

    Returns:
    - prediction report as a dict
    """
    # Pivot to get one result per league per cycle
    cycle_results = df_clean.pivot_table(
        index="Cycle_ID",
        columns="League",
        values="Result",
        aggfunc="first"
    ).dropna()

    # Filter to cycles that match the known results
    mask = pd.Series(True, index=cycle_results.index)
    for league, result in known_results.items():
        if league in cycle_results.columns:
            mask = mask & (cycle_results[league] == result)

    matching_cycles = cycle_results[mask]

What does this code do?

The function pivots your cleaned data into a cycle-level view where each row is one cycle and each column is a league's result. This is the same pivot structure you used in your correlation analysis.

It then builds a boolean mask that narrows down to only the historical cycles where the known leagues had the exact results you specify. If you say England was a Home Win, it filters to every past cycle where England was also a Home Win.

The function is not complete yet. The next chunk adds the early-return checks and the distribution computation.

Complete the scorer with distribution analysis

With the matching cycles isolated, the function now needs to compute what historically happened in the target league during those cycles. It compares that distribution against the overall baseline to calculate the uplift.

  • Continue the score_pattern function by adding this code directly below the matching_cycles = cycle_results[mask] line:

    # Return early if not enough matching data
    if len(matching_cycles) < 5:
        return {
            "status": "insufficient_data",
            "matching_cycles": len(matching_cycles),
            "message": f"Only {len(matching_cycles)} matching cycles found. Need at least 5 for reliable prediction."
        }

    if target_league not in matching_cycles.columns:
        return {"status": "error", "message": f"League '{target_league}' not found in data."}

    # Compare target league distribution in matching cycles vs all cycles
    target_dist = matching_cycles[target_league].value_counts(normalize=True)
    baseline_dist = cycle_results[target_league].value_counts(normalize=True)

    # Build prediction report with uplift calculation
    report = {
        "status": "success",
        "matching_cycles": len(matching_cycles),
        "total_cycles": len(cycle_results),
        "known_results": known_results,
        "target_league": target_league,
        "predictions": {}
    }

    for outcome in ["H", "D", "A"]:
        pred_pct = target_dist.get(outcome, 0) * 100
        base_pct = baseline_dist.get(outcome, 0) * 100
        uplift = pred_pct - base_pct
        report["predictions"][outcome] = {
            "predicted_pct": round(pred_pct, 1),
            "baseline_pct": round(base_pct, 1),
            "uplift": round(uplift, 1),
            "actionable": abs(uplift) > 10
        }

    return report

What does this code do?

  • The early-return guards prevent unreliable predictions. If fewer than 5 historical cycles match, the sample is too small to draw conclusions.
  • target_dist captures how often H/D/A occurred in the target league during matching cycles. baseline_dist captures the overall rate across all cycles.
  • The uplift is the difference between predicted and baseline percentages. An uplift above 10 percentage points is flagged as actionable.
  • Run the cell by pressing Shift+Enter.

No output appears, which is correct. Python defined the function without errors. You will call it after adding the display function next.

Seeing a SyntaxError or IndentationError?

  • Make sure all lines inside the function are indented with 4 spaces. The return report line at the bottom must be indented one level inside the function, not at the top level.
  • Check that the second chunk starts immediately after the matching_cycles line with no gap or extra blank lines breaking the function body.

Help me fix my score_pattern function indentation

Format predictions and test the scorer

The scorer returns a raw dictionary. A display function will format that into a readable table showing predicted percentages, baselines, and uplift for each outcome.

  • Add the print_prediction_report function below your score_pattern function in the same cell:

def print_prediction_report(report):
    """Pretty-print a prediction report."""
    if report["status"] != "success":
        print(f"[{report['status'].upper()}] {report['message']}")
        return

    print(f"\n{'='*60}")
    print(f"PATTERN CONFIDENCE SCORER")
    print(f"{'='*60}")
    print(f"Known results: {report['known_results']}")
    print(f"Target league: {report['target_league']}")
    print(f"Matching historical cycles: {report['matching_cycles']} / {report['total_cycles']}")
    print(f"{'-'*60}")
    print(f"{'Outcome':<12} {'Predicted':<12} {'Baseline':<12} {'Uplift':<12} {'Signal'}")
    print(f"{'-'*60}")

    for outcome, data in report["predictions"].items():
        outcome_label = {"H": "Home Win", "D": "Draw", "A": "Away Win"}[outcome]
        signal = ">>> ACTIONABLE <<<" if data["actionable"] else ""
        print(f"{outcome_label:<12} {data['predicted_pct']:>6.1f}%     {data['baseline_pct']:>6.1f}%     {data['uplift']:>+6.1f}%     {signal}")

    print(f"{'='*60}")

What does this code do?

This display function formats the raw report dictionary into a table with columns for Outcome, Predicted percentage, Baseline percentage, Uplift, and a Signal marker. Outcomes where the uplift exceeds 10 percentage points get flagged as >>> ACTIONABLE <<< so they stand out visually.

Now add the example usage code that actually calls the scorer with sample inputs from your data.

  • Add this code at the bottom of the same cell, below print_prediction_report:

# Example usage - adjust league names to match your data
leagues_in_data = df_clean["League"].unique()
print(f"Available leagues: {leagues_in_data}")
print("\nRunning example prediction...")

# Use first two leagues as known, predict third
if len(leagues_in_data) >= 3:
    example_known = {leagues_in_data[0]: "H"}
    example_target = leagues_in_data[2]

    report = score_pattern(df_clean, example_known, example_target)
    print_prediction_report(report)

    # Try another combination
    example_known_2 = {leagues_in_data[0]: "H", leagues_in_data[1]: "A"}
    report_2 = score_pattern(df_clean, example_known_2, example_target)
    print_prediction_report(report_2)
else:
    print("Need at least 3 leagues in your data to demonstrate cross-league predictions.")

What does this code do?

  • It grabs the league names from your actual data so the example works regardless of which leagues your sheet contains.
  • The first prediction uses one known result (first league = Home Win) and predicts the third league. The second prediction adds a second known result to see if more context narrows the prediction.

Before you run this, what do you expect to see? Will the predicted percentages differ from the baselines, or will they be roughly the same?

  • Run the cell by pressing Shift+Enter.

You should see two formatted prediction reports. Each shows the number of matching historical cycles, predicted percentages for Home Win, Draw, and Away Win, the baseline percentages for comparison, and the uplift values showing how much knowing the partial results shifts the prediction.

How do I read the output?

  • Matching historical cycles tells you how many past cycles had the same pattern in the known leagues. More matches means higher confidence in the prediction.
  • Uplift is the key column. A positive uplift means that outcome happens more often than usual when this pattern occurs. A negative uplift means it happens less often.
  • ACTIONABLE flags appear only when the uplift exceeds 10 percentage points above baseline. These are the signals strong enough to potentially act on.

Seeing an insufficient_data message?

  • This means fewer than 5 historical cycles matched your known results pattern. This is expected if your data has fewer than ~100 complete cycles. The scorer is being conservative by refusing to predict from too few examples.
  • Try using only one known league result instead of two. More constraints means fewer matching cycles.

Help me understand my scorer output

✔️ Awesome, I've got everything!

Great. Confirm you have saved your notebook (Colab auto-saves, but press Ctrl+S or Cmd+S to be safe).

ⓧ I'd like to double check the full code

Cell 5 is long, so confirm these sections are present in order by scrolling through your cell:

  • The score_pattern function definition with the docstring, pivot table, mask filter, early returns, distribution comparison, and report-building loop.
  • The print_prediction_report function with the formatted table output and ACTIONABLE signal markers.
  • The example usage section with leagues_in_data, two example predictions (example_known and example_known_2), and the fallback message for fewer than 3 leagues.

You now have a working analytical tool that turns raw correlations into data-backed predictions with confidence percentages. Next up, the Secret Mission challenges you to test whether these patterns are stable over time or decaying as the RNG reshuffles.

Secret mission

Rolling-Window Stability Analysis

Your correlation analysis found patterns in the full dataset. But virtual sports RNGs may reshuffle their seed cycles periodically. A pattern that was real last month might be dead today. Build a rolling-window test that checks whether your patterns are stable over time or decaying, so you know whether they are worth acting on.

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 in Google Colab and stores its notebook in your Google Drive, so there are no ongoing costs.

Resources you used:

  • Google Colab notebook Virtual Sports Pattern Analyzer (stored in Google Drive, free).

Keep everything running

No action needed. Choose this if you plan to keep collecting match data and re-running the analysis.

  • Your notebook stays in Google Drive indefinitely at no cost.
  • Every time you add new rows to your Google Sheet, open the notebook and run all cells to refresh your analysis with fresh data.
  • Colab does not keep runtimes alive indefinitely. Your variables and outputs will reset when the session disconnects, but re-running all cells restores everything.

Pause - I'll come back to this later

Nothing is actively running, so there is nothing to pause. Your notebook is already saved in Google Drive and will be there when you return.

  • Close the Colab tab in your browser. The runtime will disconnect automatically after a period of inactivity.
  • When you return, open the notebook from Google Drive and select Runtime > Run all to re-authenticate and reload your data.

Delete - I don't want to use this again

Remove the notebook from your Google Drive entirely.

  • Open Google Drive (drive.google.com).
  • Navigate to Colab Notebooks in the left sidebar or search for Virtual Sports Pattern Analyzer.
  • Right-click the notebook file and select Move to trash.
  • To permanently delete, open Trash in the left sidebar, find the notebook, right-click it, and select Delete forever.

What about your Google Sheet?

Your Google Sheet with match data existed before this project. Deleting the notebook does not affect your sheet. Keep or delete the sheet separately based on whether you still want the raw data.

Nice Work!

Nice Work!

You just built a statistical analysis notebook that mathematically tests whether the cross-league patterns in your virtual sports data are real or noise. No more guessing from AI chat responses.

You've learned how to:

  • Connect Google Colab to a live Google Sheet, clean corrupted rows, and structure 1,250+ rows of match data into an analysis-ready DataFrame.
  • Test cross-league correlations using Pearson correlation and chi-square tests, with p-values that prove whether your observed patterns are statistically significant or just noise.
  • Build a pattern confidence scorer that takes partial cycle results and outputs historically-backed predictions with confidence percentages and actionable signal flags.
  • Secret Mission: Analyzed pattern stability over time with rolling windows to determine if the RNG reshuffles its cycles, giving you a live health indicator for each correlation.

Ready to quiz yourself?