From news bursts to story graphs, with local LLMs

This post traces how tweets or news stories spread through the media and whether we can visualise the spread of news or items visually. Typically, items start with small bursts and then become full-blown stories as more people connect and discuss it online.

The plan was simple to state:

posts → people, places, organisations and claims → a graph that changes over time → “what’s happening?”

Originally, I wanted to use X f(ormerly Twitter) and listen to tweets live, but the cost of the X stream is prohibitive, so I switched to Bluesky whose whole public stream is free to read.

The the idea was to identify news/event bursts and then see how the event or news item actually becomes a full-blown story. The interesting part is what happens over the next day. So the project was created in two halves 1) finding bursts, and 2) following each burst into a story.

The setup

  • Data: Bluesky’s public stream through its free Jetstream feed, English posts only. This was done for four days from 18 to 22 October 2026, and then frozen so every number below comes from the same recording: about 8 million original posts and 7 million reposts.
  • Everything runs offline on the recording. Recording happens once; every experiment replays the saved files. That made it possible to change a threshold and compare results on identical data.
Flowchart illustrating data capture and replay processes for Bluesky Jetstream, detailing steps for polling news feeds, saving JSONL files, freezing data collection, and replaying files for analysis.
JobModel (all local)Why this one
Find names in every postGLiNER, a small entity modelfast enough for every post, about 140 posts a second
Judge whether posts say the same thingMiniLM sentence embeddingsabout 7,000 posts a second
Extract facts and claims for a storyGLM-4.7-Flash, via Ollamathe fastest model
Tamil news, merging names, captionsGemma 4 31B, via Ollamabest quality , and it reads Tamil
Store the graphNeo4j Community editionfree and local

How it works

  1. Record the Bluesky stream to hourly files.
  2. Find the names in each post: people, organisations, places, facilities, products and events.
  3. Detect bursts: a set of names that suddenly many more people are writing about than usual.
  4. Follow the story: collect its posts from 3 hours before the burst to 24 hours after.
  5. Extract facts and claims at 12 points in time with a local model, each tied to the posts that support it.
  6. Merge duplicate names, store everything in Neo4j, and draw the story as an interactive page that plays the 12 steps in order.
Flowchart illustrating data sources, capture and replay methods, and entity processing in a system. It includes information about Bluesky Jetstream, news RSS feeds, data recording, and entity cache operations.
Flowchart illustrating a process for tracking and analyzing story bursts, including steps for identifying co-mention graphs, tracking event writers and reposts, managing facts and claims, and generating knowledge graphs with outputs.

Part 1: Bursts, or finding the moment a story breaks

1. Counting hashtags finds bots

The obvious first step is to count hashtags and words and flag whatever grows fastest. In the first morning of data, the top trends were:

TrendPostsAuthors
roadworks (a UK traffic bot)5641
#kickban1971
#greatwhitemike1581
nebraska4332

Three of the top four were single accounts. Everything from here on counts distinct people, not posts, and nearly identical texts count once, because spam networks post the same template from dozens of accounts (one sent the same advert from 82).

2. Names, not hashtags

Only 11% of Bluesky posts carry a hashtag, names were needed inside the text. Gliner and Spacy entity extractors were compared on 200 posts, using Gemma 4 31B as the reference. Gemma is too slow for every post, at 0.3 posts a second, but it is accurate.

ExtractorPrecisionRecallTypes rightSpeed
spaCy (small)0.770.670.59about 460 posts a second
GLiNER0.480.810.72about 160 posts a second
GLiNER with a junk filter0.730.720.75about 160 posts a second

Unfiltered, GLiNER labelled “I’m 49”, “Dinner” and “He” as entities. A small filter (no pronouns, no dates, no common phrases in lower case) fixed most of it. Over a full hour, 53% of posts named nothing, 24% named one thing, and only 23% named two or more. Short posts mostly mention one part of a story.

3. One story, seven events

The initial detector linked names that people mention together. On the morning of 18 October, Ukrainian drones struck targets in St Petersburg: an oil terminal, a VK data centre, an Ozon warehouse and a bridge. The detector reported seven separate events. One post said “Ozon”, another “VK data center”, a third “oil terminal”, and almost nobody named two of them in one post.

4. Need for embeddings

Names alone could not tell which posts belonged to the same story, because most posts name only one part of it. I needed a way to compare what posts say, not just which names they share.

An embedding turns a post into a list of numbers, so that posts with similar meaning get similar numbers even when they share no words. “Drones hit an oil terminal in St Petersburg” and “Ozon warehouse on fire after the attack” come out close together; a post about a football score does not. A small local model (MiniLM) does this at about 7,000 posts a second, so it can be applied freely. Reading every post with a language model would understand meaning better, but Gemma manages 0.3 posts a second while 16 to 44 arrive every second.

I ended up using embeddings in four places:

WhereWhat went wrong with names aloneWhat embeddings did
Grouping a burst into one eventThe strike came out as 7 events, because “Ozon”, “VK data center” and “oil terminal” were rarely named togetherNames whose posts read alike were linked, and the strike became one event with 8 names
Following an event from one window to the nextWhen later posts used new names (“Baltic oil terminal”, “Saint Petersburg”), the strike restarted as a new eventThe new group was matched to the existing event by meaning, and the strike stayed one event for 105 minutes
Choosing the posts to read for an eventA post that only said “Nintendo” or “Korea” was pulled into unrelated stories: a 22 year old game, a Korean sports teamA post sharing only one name with the event must also read like the event
Following a story for a dayEvery passing mention of a name would have joined the storyPosts naming one of the story’s names must also be close in meaning to the story’s own posts

Two limits showed up. The threshold matters: at a similarity of 0.45, unrelated political stories merged, because political posts all read alike to a small model; at 0.65 the strike split again; 0.55 worked. And embeddings cannot rescue a story whose names are too general, which comes up in Part 2.

Each change was tested on the same three hours of data:

RunChangeEvents foundWhat happened
1names mentioned together173the strike came out as 7 events, a college football game as 7
2linking posts that read alike35the strike disappeared altogether (see the next section)
4writers counted per event60the strike became one event with 8 names
7thresholds tuned61the strike lasted 105 minutes as one event, and unrelated politics stopped merging

5. The burst formula

Putting the pieces together, this is how a burst is scored. Every 5 minutes, for every name, the detector looks at the last 15 minutes:

  • authors: the distinct people who wrote or reposted a post naming it
  • total: the distinct people active at all in those 15 minutes
  • share: authors divided by total

Each name has its own baseline: its usual share, and how much that share normally varies. Both are running averages that remember roughly the last 6 hours (a half life of 72 steps of 5 minutes):

Each part has a job:

  • Share, not count. Activity on Bluesky roughly triples between the US night and morning. Counts would make every name look like a burst each morning; shares only move when the conversation does.
  • The expected term allows for ordinary chance: a name expected 100 times can easily turn up 110 times.
  • The (total × √var)² term asks more of names that are always busy and always vary, such as Trump.
  • The + 1 stops a name never seen before from scoring infinity with two people, while a new name with 20 people still scores about 20.
  • The baseline is updated after scoring, so a burst is never compared with itself.

A name is bursting when at least 8 people named it in the window. Bursting names are grouped into events (by co-mention and by embeddings), and an event must then pass two more tests from the previous section: at least 3 people wrote original posts about it, and for at least one of its names the writers alone score at least 2 on a second baseline that counts writers only. The first 2 hours of any run only build the baselines.

Two examples. At 06:45 IST on 18 October, Ozon was named by 21 people in 15 minutes where about 0.8 would be normal, giving z = 7.6, and VK data center by 11 people where almost none was expected, giving z = 9.1: both bursts. For a constantly busy name, take an illustration: a usual share of 3% of 15,000 active people gives an expected 450; if its share normally varies by 0.4 points, the spread term adds 60, so the denominator is about 64. Then 520 people give z = 1.1, which is ordinary, and it would take about 770 to reach z = 5.

7. The burst is short

Every tech event the detector found on Monday 19 October lasted between 10 and 105 minutes. That is when people suddenly pile in. But the stories themselves ran for a day:

Bar chart showing social media post activity over time for three stories: 'St Petersburg drone strike', 'Prop 40 and Sergey Brin', and 'Nvidia and warehoused GPUs', with indicated burst detection periods.

The shaded band is all the detector saw. For the strike, the biggest wave came six hours later, in the Indian evening, as casualty figures and claims arrived. Prop 40 had a second wave the next evening. A graph built from the burst alone had 2 to 5 checkpoints: a snapshot, not a story.

Part 2: From a burst to a story

1. Following a story for a day

So the burst became a seed. Each detected event gives its main names and the average embedding of its posts. One pass over the recording, from 3 hours before the burst to 24 hours after, collects every post that names two of the story’s names, or names one and reads like the story, along with its repost count. All stories are followed in the same pass:

StoryPosts followedReposts of them
Steam Next Fest2,3127,763
Nvidia and warehoused GPUs7943,398
St Petersburg drone strike4143,784
Prop 40 and Sergey Brin3424,044
Amazon GPUs2862,965

2. Twelve checkpoints, facts and claims

The followed posts are cut into 12 slices with similar numbers of posts, so busy periods get finer steps. For each slice, GLM-4.7-Flash reads the most reposted posts (and the first one) and returns:

  • Facts as subject → verb → object (“Ukrainian drones → attacked → Ozon warehouse”), each citing the posts that state it. A fact citing no real post is dropped.
  • Claims, with who makes them: “135 drones were shot down” (the regional authorities), “our services are working normally” (VK), “the warehouse was used by the Russian defence ministry” (Ukraine; Ozon denies it).

Separating facts from attributed claims turned out to be one of the most useful parts. A story is mostly claims: casualty figures from three different sources, accusations, denials.

Infographic showing the impact of a drone attack in St. Petersburg, highlighting targeted infrastructure such as oil terminals, data centers, and warehouses.

3. Many names for one thing

Extraction runs slice by slice, and the same thing comes back under different names: “Vijay”, “CM Vijay”, “C Joseph Vijay” and “TN CM”; “IMD”, “India Meteorological Department” and “weather department”. Three layers fixed most of it:

  1. Prevention: each slice is given the names already used for this story, with an instruction to reuse them, plus a short list of preferred verbs (“attacked”, “located in”, “deployed to” and so on).
  2. Clean up in code: “X says Y” becomes a claim by X, not a fact; a fact linking a thing to itself is dropped; “VK data centre was hit by a drone” is turned round to “drone hit VK data centre”.
  3. One Gemma call per story groups the names that mean the same thing and picks one, while being told that a district is not its city (North Chennai is not Chennai).

On the Chennai rain story (below), this cut 127 different names to 70, and 42 different verbs to 11. Gemma also knew that “Kovai” is Coimbatore and that “the Tamil Nadu Weatherman” is the blogger Pradeep John. It made mistakes too: it merged “weather centre”, which in Tamil reports usually means Chennai’s regional centre, into the national IMD.

4. Captions that say only what’s new

My first pages showed each slice’s summary, and every summary began by restating the whole story. So Gemma now writes one fixed sentence for the story, and one sentence per step with only what is new. For Prop 40, California’s proposed tax on billionaires:

Story: Users debate California’s proposed billionaire wealth tax (Prop 40) and criticise tech billionaires’ ties to Donald Trump and Mar-a-Lago.

  1. Users suggest renaming Mar-a-Lago and discuss Props 41 and 42.
  2. Sergey Brin reportedly spent over $100 million against Prop 40 and dined with Trump at Mar-a-Lago.
  3. Reports that Mark Zuckerberg moved to Florida and Gavin Newsom vacationed with Brin.
  4. Musk, Thiel and Zuckerberg reportedly oppose Prop 40; Bernie Sanders supports it.
  5. A poll suggests Prop 40 faces a “spectacular collapse”.
  6. Brin reportedly moved to Nevada and bought a Miami home; the BBC reportedly demanded Mar-a-Lago’s membership list.
  7. Economist Gabriel Zucman explains Prop 40 as a wealth tax; Mark Ruffalo warns of health insurance losses.

And for the strike, the account firms up over the day: targets first, then the radar site and bridge, then Ozon’s denial, then casualty figures from three different sources (Zelensky, the Russian defence ministry and the city’s mayor, each with different numbers), then “over 1,000 drones, the largest attack on the city”. These are what posts claimed, not checked facts, which is why every claim keeps its source.

A visual map illustrating connections related to California's proposed billionaire wealth tax, featuring Sergey Brin's move to Nevada and a Miami purchase, alongside claims related to Mar-a-Lago and political figures like Donald Trump and Peter Thiel.

The pages play the 12 steps in order, with a speed control (2 to 10 seconds per step) and each step’s caption over the graph. They work on a phone.

5. When following goes wrong

The Nvidia story shows the weakness. Its seed names were “Nvidia”, “GPUs”, “RAM” and “Jensen Huang”, all of which people discuss constantly. So the day’s story became everything about Nvidia: warehoused GPUs, “channel stuffing” accusations, a German AI cloud deal, a new CPU. Every Nvidia post read like the story, so embeddings could not hold it together either. You can see it in the figure above: a flat, steady line rather than a wave. A story can only be followed if its names are specific to it. Steam Next Fest, a festival of hundreds of game demos, had the opposite problem: a few prolific developers filled the evidence with their own game.

A detour to Chennai: a story told in two languages

Heavy northeast monsoon rain hit Chennai on 17 and 18 October while I was recording. Bluesky had almost nothing about it (128 posts in 21 hours, mostly automatic copies of Tamil TV headlines), so I followed 1,621 news articles instead, in English and Tamil, alongside Open-Meteo’s hourly rainfall.

  • GLM cannot read Tamil. It returned nothing for seven Tamil headlines. Gemma 4 31B read them and wrote every name in English (சென்னை → Chennai, தாம்பரம் → Tambaram), so Tamil and English articles about the same place joined up in the graph.
  • News arrived the morning after. The heaviest rain fell around 00:30 IST; articles peaked six to nine hours later, and Tamil outlets were out first, from 06:30, with English coverage peaking around midday.
Bar graph showing rainfall (in mm/h) in central Chennai over time with a peak of 16 mm/h. Below, line graphs show the number of Tamil news articles, English news articles, and Bluesky posts about Chennai weather over the same period.
  • The figures firmed up through the day: 175 mm at Parrys, then “30 cm in Chennai”, then 36.34 cm overnight. The official and critical accounts sat side by side: the Corporation’s “no fatalities” and “drained by dawn, barring Perambur and Velachery”, a minister’s “Chennai cannot withstand 24 hours of continuous rain”, and a resident’s “36 cm fell in 3 hours and the weather centre gave no warning”.

One caveat: the rainfall model under reads badly. It peaked at 16 mm an hour, while the reported totals were ten times higher. It gets the timing right, not the amount.

Play the stories

Each story is an interactive page: press Play to watch it build up step by step, with a caption saying what changed, and use the slider to jump to any step. They work on a phone too. All eight are listed at https://tvganesh.github.io/bluesky-stories/.

The pages show what the models extracted, not the posts themselves: account names, links to posts and quoted posts have been removed, and posts their authors deleted after I recorded them were dropped.

This was an interesting exercise to see how the stories develop. What would be more interesting is so what cause tweets or stories to go viral. That is definitely food for thought.

Also see

To see all posts click Index of posts

Computing IPL player similarity using Embeddings, Deep Learning

In this post, I revisit the visualisation of IPL batsman and bowler similarities using Google’s Embedding Projector. I had previously done this using multivariate regression in my earlier post ‘Using embeddings, collaborative filtering with Deep Learning to analyse T20 players.’ However, I was not too satisfied with the result since I was not getting the required accuracy.

This post uses the win-loss status of IPL matches from 2014 onwards upto 2023 in Logistic Regression with Deep Learning. A 16-dimensional embedding layer is added for the batsman and the bowler for ball-by-ball data. Since I have used a reduced size data set (from 2014) I get a slightly reduced accuracy, but still I think this is a well-formulated problem.

A Deep Learning network performs gradient descent based using Adam optimisation to arrive at an accuracy of 0.8047. The weights of the learnt Deep Learning network in ‘layer 0’ is used for displaying the batsman and bowler similarities.

Similarity measures – Cosine similarity

A cosine similarity is a value that is bound by a constrained range of 0 and 1. The closer the value is to 0 means that the two vectors are orthogonal or perpendicular to each other. When the value is closer to one, it means the angle is smaller and the batsman and bowler are similar.

a) Data set

For the data set only IPL T20 matches from Jan 2014 upto the present (May 2023) was taken. A Deep Learning model using Logistic Regression with batsman and bowler embedding is used to minimise the error. An accuracy of 0.8047 is obtained. In my earlier post ‘GooglyPlusPlus: Win Probability using Deep Learning and player embeddings‘ I had used data from all T20 leagues (~1.2 million rows) and got an accuracy of 0.8647

b) Import the data

import pandas as pd
import numpy as np
from zipfile import ZipFile
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras import regularizers
from pathlib import Path
import matplotlib.pyplot as plt

import pandas as pd
import numpy as np
from zipfile import ZipFile
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras import regularizers

df1=pd.read_csv('ipl2014_23.csv')
print("Shape of dataframe=",df1.shape)

train_dataset = df1.sample(frac=0.8,random_state=0)
test_dataset = df1.drop(train_dataset.index)
train_dataset1 = train_dataset[['batsmanIdx','bowlerIdx','ballNum','ballsRemaining','runs','runRate','numWickets','runsMomentum','perfIndex']]
test_dataset1 = test_dataset[['batsmanIdx','bowlerIdx','ballNum','ballsRemaining','runs','runRate','numWickets','runsMomentum','perfIndex']]
train_dataset1
train_labels = train_dataset.pop('isWinner')
test_labels = test_dataset.pop('isWinner')
train_dataset1

a=train_dataset1.describe()
stats=a.transpose

Shape of dataframe= (138896, 10)
batsmanIdx	bowlerIdx	ballNum	ballsRemaining	runs	runRate	numWickets	runsMomentum	perfIndex
count	111117.000000	111117.000000	111117.000000	111117.000000	111117.000000	111117.000000	111117.000000	111117.000000	111117.000000
mean	218.672939	169.204145	120.372067	60.749822	86.881701	1.636353	2.423167	0.296061	10.578927
std	118.405729	96.934754	69.991408	35.298794	51.643164	2.672564	2.085956	0.620872	4.436981
min	1.000000	1.000000	1.000000	1.000000	-5.000000	-5.000000	0.000000	0.057143	0.000000
25%	111.000000	89.000000	60.000000	30.000000	45.000000	1.160000	1.000000	0.106383	7.733333
50%	220.000000	170.000000	119.000000	60.000000	85.000000	1.375000	2.000000	0.142857	10.329545
75%	325.000000	249.000000	180.000000	91.000000	126.000000	1.640000	4.000000	0.240000	13.108696
max	411.000000	332.000000	262.000000	135.000000	258.000000	251.000000	10.000000	11.000000	66.000000

c) Create a Deep Learning ML model using batsman & bowler embeddings

import pandas as pd
import numpy as np
from keras.layers import Input, Embedding, Flatten, Dense
from keras.models import Model
from keras.layers import Input, Embedding, Flatten, Dense, Reshape, Concatenate, Dropout
from keras.models import Model

tf.random.set_seed(432)
# create input layers for each of the predictors
batsmanIdx_input = Input(shape=(1,), name='batsmanIdx')
bowlerIdx_input = Input(shape=(1,), name='bowlerIdx')
ballNum_input = Input(shape=(1,), name='ballNum')
ballsRemaining_input = Input(shape=(1,), name='ballsRemaining')
runs_input = Input(shape=(1,), name='runs')
runRate_input = Input(shape=(1,), name='runRate')
numWickets_input = Input(shape=(1,), name='numWickets')
runsMomentum_input = Input(shape=(1,), name='runsMomentum')
perfIndex_input = Input(shape=(1,), name='perfIndex')

# Set the embedding size
no_of_unique_batman=len(df1["batsmanIdx"].unique())
print(no_of_unique_batman)
no_of_unique_bowler=len(df1["bowlerIdx"].unique())
print(no_of_unique_bowler)
embedding_size_bat = no_of_unique_batman ** (1/4)
embedding_size_bwl = no_of_unique_bowler ** (1/4)


# create embedding layer for the categorical predictor
batsmanIdx_embedding = Embedding(input_dim=no_of_unique_batman+1, output_dim=16,input_length=1)(batsmanIdx_input)
batsmanIdx_flatten = Flatten()(batsmanIdx_embedding)
bowlerIdx_embedding = Embedding(input_dim=no_of_unique_bowler+1, output_dim=16,input_length=1)(bowlerIdx_input)
bowlerIdx_flatten = Flatten()(bowlerIdx_embedding)

# concatenate all the predictors
x = keras.layers.concatenate([batsmanIdx_flatten,bowlerIdx_flatten, ballNum_input, ballsRemaining_input, runs_input, runRate_input, numWickets_input, runsMomentum_input, perfIndex_input])

# add hidden layers
#x = Dense(64, activation='relu')(x)
#x = Dropout(0.1)(x)
x = Dense(32, activation='relu')(x)
x = Dropout(0.1)(x)
x = Dense(16, activation='relu')(x)
x = Dropout(0.1)(x)
x = Dense(8, activation='relu')(x)
x = Dropout(0.1)(x)
# add output layer
output = Dense(1, activation='sigmoid', name='output')(x)
print(output.shape)
# create model
model = Model(inputs=[batsmanIdx_input,bowlerIdx_input, ballNum_input, ballsRemaining_input, runs_input, runRate_input, numWickets_input, runsMomentum_input, perfIndex_input], outputs=output)
model.summary()

# compile model
optimizer=keras.optimizers.Adam(learning_rate=.01, beta_1=0.9, beta_2=0.999, epsilon=1e-07, amsgrad=True)

model.compile(optimizer=optimizer, loss='binary_crossentropy', metrics=['accuracy'])

# train the model
history=model.fit([train_dataset1['batsmanIdx'],train_dataset1['bowlerIdx'],train_dataset1['ballNum'],train_dataset1['ballsRemaining'],train_dataset1['runs'],
           train_dataset1['runRate'],train_dataset1['numWickets'],train_dataset1['runsMomentum'],train_dataset1['perfIndex']], train_labels, epochs=40, batch_size=1024,
          validation_data = ([test_dataset1['batsmanIdx'],test_dataset1['bowlerIdx'],test_dataset1['ballNum'],test_dataset1['ballsRemaining'],test_dataset1['runs'],
           test_dataset1['runRate'],test_dataset1['numWickets'],test_dataset1['runsMomentum'],test_dataset1['perfIndex']],test_labels), verbose=1)

plt.plot(history.history["loss"])
plt.plot(history.history["val_loss"])
plt.title("model loss")
plt.ylabel("loss")
plt.xlabel("epoch")
plt.legend(["train", "test"], loc="upper left")
plt.show()

d) Project embeddings with Google’s Embedding projector

try:
  # %tensorflow_version only exists in Colab.
  %tensorflow_version 2.x
except Exception:
  pass

%load_ext tensorboard
import os
import tensorflow as tf
import tensorflow_datasets as tfds
from tensorboard.plugins import projector

%pwd
# Set up a logs directory, so Tensorboard knows where to look for files.
log_dir='/logs/batsmen/'
if not os.path.exists(log_dir):
    os.makedirs(log_dir)

df3=pd.read_csv('batsmen.csv')
batsmen = df3["batsman"].unique().tolist()
batsmen
# Create dictionary of batsman to index
batsmen2index = {x: i for i, x in enumerate(batsmen)}
batsmen2index
# Create dictionary of index to batsman
index2batsmen = {i: x for i, x in enumerate(batsmen)}
index2batsmen


# Save Labels separately on a line-by-line manner.
with open(os.path.join(log_dir, 'metadata.tsv'), "w") as f:
  for batsmanIdx in range(1, 411):
       # Get the name of batsman associated at the current index
        batsman = index2batsmen.get([batsmanIdx][0])

        f.write("{}\n".format(batsman))

# Save the weights we want to analyze as a variable. Note that the first
# value represents any unknown word, which is not in the metadata, here
# we will remove this value.
weights = tf.Variable(model.get_weights()[0][1:])
print(weights)
print(type(weights))
print(len(model.get_weights()[0]))
# Create a checkpoint from embedding, the filename and key are the
# name of the tensor.
checkpoint = tf.train.Checkpoint(embedding=weights)
checkpoint.save(os.path.join(log_dir, "embedding.ckpt"))

# Set up config.
config = projector.ProjectorConfig()
embedding = config.embeddings.add()
# The name of the tensor will be suffixed by `/.ATTRIBUTES/VARIABLE_VALUE`.
embedding.tensor_name = "embedding/.ATTRIBUTES/VARIABLE_VALUE"
embedding.metadata_path = 'metadata.tsv'
projector.visualize_embeddings(log_dir, config)
# Now run tensorboard against on log data we just saved.

%reload_ext tensorboard
%tensorboard --logdir /logs/batsmen/

e) Here are similarity measures for some batsmen

I) Principal Component Analysis (PCA) : In the charts and video animation below, the 16-dimensional embedding vector of batsmen and bowler is reduced to 3 principal components in a lower dimension for visualisation and analysis as shown below

a) Yashasvi Jaiswal (similar players

i) PCA – Chart

Yashasvi Jaiswal style of attack is similar to Faf Du Plessis, Quentim De Kock, Bravo etc. In the below chart the ange between Jaiswal and SP Narine is 0.109, and Faf du Plessis is 0.253. These represent the angle in radians. The smaller the angle the more similar the performance style of the players and cos 0=1 or the players are similar.

ii) PCA animation video for Yashasvi Jaiswal

b) Suryakumar Yadav (SKY)

i) PCA -Chart

The closest neighbours for SKY is RV Uthappa, Rahul Tripathi, Q de Kock, Samson, Rashid Khan

ii) PCA – Animation video for Suryakumar Yadav

c) M S Dhoni

i) PCA – Chart

Dhoni rubs shoulders with Bravo, AB De Villiers, Shane Watson, Chris Gayle, Rayadu, Gautam Gambhir

ii) PCA – Animation video for M S Dhoni

f) PCA Analysis for bowlers

a) Jasprit Bumrah

i) PCA – Chart

Bumrah bowling performance is similar to Josh Hazzlewood, Chameera, Kuldeep Yadav, Nortje, Adam Zampa etc.

ii) PCA Animation video for Jasprit Bumrah

b) Yuzhvendra Chahal

i) PCA – Chart

Chahal’s performance has a strong similarity to Malinga, Zaheer Khan, Imran Tahir, R Sheperd, Adil Rashid

ii) PCA Animation video for YS Chahal

f) Other similarity measures ( t-SNE & UMAP)

There are 2 other similarity visualisations in Google’s Embedding Projector namely

i) t-SNE (t-distributed Stochastic Neighbor Embedding) – t-SNE tries to find a faithful representation of the data distribution in higher dimensional space to a lower dimensional space. t-SNE differs from PCA by preserving only or local similarities whereas PCA is maintains preserving large pairwise distances.

a) t-SNE Animation video

ii) UMAP – Uniform Manifold Approximation and Projection

UMAP learns the manifold structure of the high dimensional data and finds a low dimensional embedding that preserves the essential topological structure of that manifold.

ii) UMAP – Animation video

The Embedding projector thus helps in identifying players based on how they perform against bowlers, and probably picks up a lot of features like strike rate and performance in different stages of the game.

Hope you enjoyed the post!

Also see

  1. Exploring Quantum Gate operations with QCSimulator
  2. De-blurring revisited with Wiener filter using OpenCV
  3. Using Reinforcement Learning to solve Gridworld
  4. Deep Learning from first principles in Python, R and Octave – Part 4
  5. Big Data 6: The T20 Dance of Apache NiFi and yorkpy
  6. Latency, throughput implications for the Cloud
  7. Programming languages in layman’s language
  8. Practical Machine Learning with R and Python – Part 6
  9. Using Linear Programming (LP) for optimizing bowling change or batting lineup in T20 cricket
  10. A closer look at “Robot Horse on a Trot” in Android

To see all posts click Index of posts

Using embeddings, collaborative filtering with Deep Learning to analyse T20 players

There is a school of thought which considers that total runs scored and strike rate for a batsman, or total wickets taken and economy rate for a bowler, do not tell the whole story. This is true to a fair extent. The runs scored or the wickets taken could have been against weaker teams and hence the runs, strike rate or the wickets and economy rate alone do not capture all the performance details of the batsman or bowler. A technique to determine the performance of batsmen against different bowlers and identify the batsman’s possible performance even against bowlers he/she has not yet faced could be done with collaborative filtering. Collaborative filtering, with embeddings can also be used to group players with similar characteristics. Similarly, we could also identify the performance of bowlers versus different batsmen. Hence we need to look at average runs, SR and total wickets, ER with the lens of batsmen, bowlers against similar opposition. This is where collaborative filtering is useful.

The table below shows the performance of all batsman against all bowlers in the table below. The row in the table below is the batsman and the column is the bowler, with the value in the cell is the total Runs scored by the batsman against the bowler in all matches. Note the values are 0 for batsmen who have not yet faced specific bowlers. The table is fairly sparse.

Table A

Similarly, we can compute the performance of all bowlers against all batsmen as in the table below. Here the row is the bowler, the column batsman and the value in the cell is the number of times the bowler got the batsman’s wicket. As before the data is sparsely populated

This problem of computing batsman’s performance against bowlers or vice versa, is identical to the user vs movie rating problem used in collaborative filtering. For e.g we could consider

This above problem depicted could be computed using collaborative filtering with embeddings. We could assign sequential numbers for the batsmen from 1 to M, and for the bowlers from 1 to N. The total runs scored could be represented only for the rows where there are values. One way to solve this problem in Machine Learning is to use One Hot Encoding (OHE), where we assign values for each row and each column and map the values of the table with values of the cell for each combination. But this would take a enormous computation time and memory. The solution to this is use vector embeddings. Here embeddings could be used for capturing the sparse tensors between the batsmen, bowlers, runs scored or vice versa between bowlers against batsmen and the wickets taken. We only need to consider the cells for which values exist. An embedding is a relatively low-dimensional space, into which you can translate high-dimensional vectors. An embedding captures some of the semantics of the input by placing semantically similar inputs close together in the embedding space.

a) To compute bowler performances and identify similarities between bowlers the following embedding in the Deep Learning Network was used

To compute batsmen similarities a similar Deep Learning network for bowler vs batsmen is used

I had earlier created another post Player Performance Estimation using AI Collaborative Filtering for batsman and bowler recommendation, using R package Recommender Lab. However, I was not too happy with the results I got with this R package. When I searched the net for material on using embeddings for collaborative filtering, most of material on the web on movie lens or word2vec are repetitive and have no new material. Finally, this short video lecture from Developer Google on Embeddings provided the most clarity.

I have created 4 Colab notebooks to identify player similarities (recommendations)

a) Batsman similarities IPL

b) Batsman similarities T20

c) Bowler similarities IPL

d) Bowler similarities T20

For creating the model I have used all the data for T20 and IPL from so that I get the best results. The data is from Cricsheet. I have also used Google’s Embeddings Projector to display batsman and bowler embedding to and to group similar players

All the Colab notebooks and the data associated with the code are available in Github. Feel free to download and execute them. See if you get better performance. I tried a wide variety of hyperparameters – learning rate, width and depth of nodes per layer, number of layers, gradient methods etc.

You can download all the code & data from Github at embeddings

A) Batsman Recommender IPL (BatsmanRecommenderIPLA.ipynb)

Steps for creating the model

a) Upload bowler vs batsmen with times wicket was taken for batsman. This will be a sparse matrix

b) Assign integer indices for bowlers, batsmen

c) Add additional input features balls, runs conceded and Economy rate

d) Minimise loss for wickets taken for the bowler using SGD

e) Display embeddings of similar batsmen using Tensorboard projector

a) Upload data

Upload data file
2. Remove rows where wickets = 0

from google.colab import files
import io
uploaded = files.upload()
df2 = pd.read_csv(io.BytesIO(uploaded['bowlerVsBatsmanIPLE.csv']))
print(df2.shape)
df2 = df2.loc[df2['wicketTaken']!= 0]
print(df2.shape)

uploaded = files.upload()
df6 = pd.read_csv(io.BytesIO(uploaded['bowlerVsBatsmanIPLAll.csv']))
df6
     


Out[14]:

bowler1batsman1ballsrunsConcededER
0A Ashish ReddyDJG Sammy100.000000
1A Ashish ReddyG Gambhir101710.200000
2A Ashish ReddyJEC Franklin200.000000
3A Ashish ReddyLRPL Taylor567.200000
4A Ashish ReddyMA Agarwal3714.000000
………………
8550Z KhanVishnu Vinod4812.000000
8551Z KhanVS Malik3510.000000
8552Z KhanW Jaffer732.571429
8553Z KhanYK Pathan22359.545455
8554Z KhanYuvraj Singh12126.000000

b) Create integer dictionaries for batsmen & bowlers

bowlers = df3["bowler1"].unique().tolist()
bowlers
# Create dictionary of bowler to index
bowlers2index = {x: i for i, x in enumerate(bowlers)}
bowlers2index
#Create dictionary of index tp bowler
index2bowlers = {i: x for i, x in enumerate(bowlers)}
index2bowlers


batsmen = df3["batsman1"].unique().tolist()
batsmen
# Create dictionary of batsman to index
batsmen2index = {x: i for i, x in enumerate(batsmen)}
batsmen2index
# Create dictionary of index to batsman
index2batsmen = {i: x for i, x in enumerate(batsmen)}
index2batsmen

#Map bowler, batsman to respective indices
df3["bowler"] = df3["bowler1"].map(bowlers2index)
df3["batsman"] = df3["batsman1"].map(batsmen2index)
df3
num_bowlers =len(bowlers2index)
num_batsmen = len(batsmen2index)
df3["wicketTaken"] = df3["wicketTaken"].values.astype(np.float32)
df3
# min and max ratings will be used to normalize the ratings later
min_wicketTaken = min(df3["wicketTaken"])
max_wicketTaken = max(df3["wicketTaken"])

print(
    "Number of bowlers: {}, Number of batsmen: {}, Min wicketsTaken: {}, Max wicketsTaken: {}".format(
        num_bowlers, num_batsmen, min_wicketTaken, max_wicketTaken
    )
)

c) Concatenate additional features

df3
df6
df31=pd.concat([df3,df6],axis=1)
df31

d) Create a Tensorflow/Keras deep learning mode. Minimise using Mean Squared Error using Stochastic Gradient Descent. I used ‘dropouts’ to regularise the model to keep validation loss within limits

tf.random.set_seed(4)
vector_size=len(batsmen2index)

df4=df31[['bowler','batsman','wicketTaken','balls','runsConceded','ER']]
df4
train_dataset = df4.sample(frac=0.9,random_state=0)
test_dataset = df4.drop(train_dataset.index)

train_dataset1 = train_dataset[['bowler','batsman','balls','runsConceded','ER']]
test_dataset1 = test_dataset[['bowler','batsman','balls','runsConceded','ER']]
train_stats = train_dataset1.describe()
train_stats = train_stats.transpose()
#print(train_stats)

train_labels = train_dataset.pop('wicketTaken')
test_labels = test_dataset.pop('wicketTaken')

# Create a Deep Learning model with keras
model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vector_size,16,input_length=5),
    tf.keras.layers.Flatten(),
    keras.layers.Dropout(.2),
    keras.layers.Dense(16),
 
    keras.layers.Dense(8,activation=tf.nn.relu),
    
    keras.layers.Dense(4,activation=tf.nn.relu),
    keras.layers.Dense(1)
  ])

# Print the model summary
#model.summary()
# Use the Adam optimizer with a learning rate of 0.01
#optimizer=keras.optimizers.Adam(learning_rate=.0009, beta_1=0.5, beta_2=0.999, epsilon=None, decay=0.0, amsgrad=True)
#optimizer=keras.optimizers.RMSprop(learning_rate=0.01, rho=0.2, momentum=0.2, epsilon=1e-07)
#optimizer=keras.optimizers.SGD(learning_rate=.009,momentum=0.1) - Works without dropout
optimizer=keras.optimizers.SGD(learning_rate=.01,momentum=0.1)

model.compile(loss='mean_squared_error',
                optimizer=optimizer,
                )

 # Setup the training parameters
#model.compile(loss='binary_crossentropy',optimizer='rmsprop',metrics=['accuracy'])
# Create a model
history=model.fit(
  train_dataset1, train_labels,batch_size=32,
  epochs=40, validation_data = (test_dataset1,test_labels), verbose=1)

e) Plot losses

f) Predict wickets that will be taken by bowlers against random batsmen


df5= df4[['bowler','batsman','balls','runsConceded','ER']]
test1 = df5.sample(n=10)
test1.shape
for i in range(test1.shape[0]):
      print('Bowler :', index2bowlers.get(test1.iloc[i,0]), ", Batsman : ",index2batsmen.get(test1.iloc[i,1]), '- Times wicket Prediction:',model.predict(test1.iloc[[i]]))
1/1 [==============================] - 0s 90ms/step
Bowler : Harbhajan Singh , Batsman :  AM Nayar - Times wicket Prediction: [[1.0114906]]
1/1 [==============================] - 0s 18ms/step
Bowler : T Natarajan , Batsman :  Arshdeep Singh - Times wicket Prediction: [[0.98656166]]
1/1 [==============================] - 0s 19ms/step
Bowler : KK Ahmed , Batsman :  A Mishra - Times wicket Prediction: [[1.0504484]]
1/1 [==============================] - 0s 24ms/step
Bowler : M Muralitharan , Batsman :  F du Plessis - Times wicket Prediction: [[1.0941994]]
1/1 [==============================] - 0s 25ms/step
Bowler : SK Warne , Batsman :  DR Smith - Times wicket Prediction: [[1.0679393]]
1/1 [==============================] - 0s 28ms/step
Bowler : Mohammad Nabi , Batsman :  Ishan Kishan - Times wicket Prediction: [[1.403399]]
1/1 [==============================] - 0s 32ms/step
Bowler : R Bhatia , Batsman :  DJ Thornely - Times wicket Prediction: [[0.89399755]]
1/1 [==============================] - 0s 26ms/step
Bowler : SP Narine , Batsman :  MC Henriques - Times wicket Prediction: [[1.1997008]]
1/1 [==============================] - 0s 19ms/step
Bowler : AS Rajpoot , Batsman :  K Gowtham - Times wicket Prediction: [[0.9911405]]
1/1 [==============================] - 0s 21ms/step
Bowler : K Rabada , Batsman :  P Simran Singh - Times wicket Prediction: [[1.0064855]]

g) The embedding can be visualised using Google’s Embedding Projector, which identifies other batsmen who have similar characteristics. Here Cosine Similarity is used for grouping similar batsmen of IPL

The closest neighbor for AB De Villiers in IPL is SK Raina, then Rohit Sharma as seen in the visualisation below

B. Bowler Recommender T20 (BowlerRecommenderT20M1A.ipynb)

Similar to how batsman was set up,

The steps are

a) Upload data for T20 Batsman vs Bowler with Total runs scored. This will be a sparse matrix

b) Create integer dictionaries for batsman & bowler

c) Add additional features like fours, sixes and strike rate

d) Minimise loss for wicket taken

e) Display embeddings of bowlers using Tensorboard Embeddings Projector

Minimizing the loss for wicket taken using SGD

tf.random.set_seed(4)
vector_size=len(batsman2index)

#Normalize target variable
df4=df31[['bowler','batsman','totalRuns','fours','sixes','ballsFaced']]
df4['normalizedRuns'] = (df4['totalRuns'] -df4['totalRuns'].mean())/df4['totalRuns'].std()
print(df4)
train_dataset = df4.sample(frac=0.8,random_state=0)
test_dataset = df4.drop(train_dataset.index)
train_dataset1 = train_dataset[['batsman','bowler','fours','sixes','ballsFaced']]
test_dataset1 = test_dataset[['batsman','bowler','fours','sixes','ballsFaced']]

train_labels = train_dataset.pop('normalizedRuns')
test_labels = test_dataset.pop('normalizedRuns')
train_labels
print(train_dataset1)

# Create a Deep Learning model with keras
model = tf.keras.Sequential([
    tf.keras.layers.Embedding(vector_size,16,input_length=5),
    tf.keras.layers.Flatten(),
    keras.layers.Dropout(.2),
    keras.layers.Dense(16),
 
    keras.layers.Dense(8,activation=tf.nn.relu),
    
    keras.layers.Dense(4,activation=tf.nn.relu),
    keras.layers.Dense(1)
  ])

# Print the model summary
#model.summary()
# Use the Adam optimizer with a learning rate of 0.01
#optimizer=keras.optimizers.Adam(learning_rate=.0009, beta_1=0.5, beta_2=0.999, epsilon=None, decay=0.0, amsgrad=True)
#optimizer=keras.optimizers.RMSprop(learning_rate=0.001, rho=0.2, momentum=0.2, epsilon=1e-07)
#optimizer=keras.optimizers.SGD(learning_rate=.009,momentum=0.1) - Works without dropout
optimizer=keras.optimizers.SGD(learning_rate=.01,momentum=0.1)

model.compile(loss='mean_squared_error',
                optimizer=optimizer,
                )

 # Setup the training parameters
#model.compile(loss='binary_crossentropy',optimizer='rmsprop',metrics=['accuracy'])
# Create a model
history=model.fit(
  train_dataset1, train_labels,batch_size=32,
  epochs=40, validation_data = (test_dataset1,test_labels), verbose=1)
model.predict(train_dataset1[1:10])
df5= df4[['batsman','bowler','fours','sixes','ballsFaced']]
test1 = df5.sample(n=10)
model.predict(test1)
#(model.predict(test1)* df4['totalRuns'].std()) + df4['totalRuns'].mean()
for i in range(test1.shape[0]):
        print('Batsman :', index2batsman.get(test1.iloc[i,0]), ", Bowler : ",index2bowler.get(test1.iloc[i,1]), '- Total runs Prediction:',(model.predict(test1.iloc[i])* df4['totalRuns'].std()) + df4['totalRuns'].mean())
1/1 [==============================] - 0s 396ms/step
1/1 [==============================] - 0s 112ms/step
1/1 [==============================] - 0s 183ms/step
Batsman : G Chohan , Bowler :  Khawar Ali - Total runs Prediction: [[1.8883028]]
1/1 [==============================] - 0s 56ms/step
Batsman : Umar Akmal , Bowler :  LJ Wright - Total runs Prediction: [[9.305391]]
1/1 [==============================] - 0s 68ms/step
Batsman : M Shumba , Bowler :  Simi Singh - Total runs Prediction: [[19.662743]]
1/1 [==============================] - 0s 30ms/step
Batsman : CH Gayle , Bowler :  RJW Topley - Total runs Prediction: [[16.854687]]
1/1 [==============================] - 0s 39ms/step
Batsman : BA King , Bowler :  Taskin Ahmed - Total runs Prediction: [[3.5154686]]
1/1 [==============================] - 0s 102ms/step
Batsman : KD Shah , Bowler :  Avesh Khan - Total runs Prediction: [[8.411661]]
1/1 [==============================] - 0s 38ms/step
Batsman : ST Jayasuriya , Bowler :  SCJ Broad - Total runs Prediction: [[5.867449]]
1/1 [==============================] - 0s 45ms/step
Batsman : AB de Villiers , Bowler :  Saeed Ajmal - Total runs Prediction: [[15.150892]]
1/1 [==============================] - 0s 46ms/step
Batsman : SV Samson , Bowler :  J Little - Total runs Prediction: [[10.44426]]
1/1 [==============================] - 0s 102ms/step
Batsman : Zawar Farid , Bowler :  GJ Delany - Total runs Prediction: [[1.9770675]]

Identifying similar bowlers using Embeddings Projector for T20

Bhuvaneshwar Kumar’s performance is closest to CR Woakes

Note: Incidentally the accuracy in the above model was not too good. I may work on this again later!

C) Bowler Embeddings IPL – Grouping similar bowlers of IPL with Embeddings Projector (BowlerRecommenderIPLA.ipynb)

D) Batting Embeddings T20 – Grouping similar batsmen of T20 (BatsmanRecommenderT20MA.ipynb)

The Tensorboard Pmbeddings projector is also interesting. There are multiple ways the data can be visualised namely UMAP, T-SNE, PCA(included). You could play with it.

As mentioned above the Colab notebooks and data are available at Github embeddings

The ability to identify batsmen & bowlers who would perform similarly against specific bowling attacks coupled with the average runs & strike rate should give a good measure of a player’s performance.

Take a look at some of my other posts

  1. Using Reinforcement Learning to solve Gridworld
  2. Deep Learning from first principles in Python, R and Octave – Part 4
  3. Big Data 7: yorkr waltzes with Apache NiFi
  4. Programming languages in layman’s language
  5. Pitching yorkpy…swinging away from the leg stump to IPL – Part 3
  6. Re-introducing cricketr! : An R package to analyze performances of cricketers
  7. The making of Total Control Android game
  8. Presentation on “Intelligent Networks, CAMEL protocol, services & applications”
  9. Exploring Quantum Gate operations with QCSimulator

To see all posts click Index of posts