<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-07-03T15:02:37+00:00</updated><id>/feed.xml</id><title type="html">dfnt.xyz</title><subtitle>A website about the work I do in AI and computer security. Have a look around!</subtitle><entry><title type="html">Bluesky Search</title><link href="/projects/ml/2026/05/23/bskysearch.html" rel="alternate" type="text/html" title="Bluesky Search" /><published>2026-05-23T12:00:00+00:00</published><updated>2026-05-23T12:00:00+00:00</updated><id>/projects/ml/2026/05/23/bskysearch</id><content type="html" xml:base="/projects/ml/2026/05/23/bskysearch.html"><![CDATA[<p>The idea is simple. Given a user with their general “Following” feed (the one that collates the posts from everyone you follow), search the feed for information that matches closely with the user input.</p>

<p>There are a few ways of doing this. In the pre-AI era, this could be done using keywords, with high-ranking results containing more of the searched keywords than lower-ranking ones, but this isn’t really how things are done anymore. More modern approaches use LLMs, leveraging their ability to understand context, so if we search for “fast food chips”, we’ll have high-ranking food results rather than computer chips.</p>

<p>With LLMs in mind, my approach to search is:</p>
<ul>
  <li>Take each post from my “Following” feed</li>
  <li>Use an LLM to embed the post in an embedding space</li>
  <li>Search for something, being vague or specific</li>
  <li>Return the posts that are close to the search text in the concept space</li>
</ul>

<p>This is the code:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">atproto</span> <span class="kn">import</span> <span class="n">Client</span>

<span class="kn">import</span> <span class="nn">os</span>

<span class="kn">from</span> <span class="nn">sentence_transformers</span> <span class="kn">import</span> <span class="n">SentenceTransformer</span>
<span class="kn">from</span> <span class="nn">langchain_community.embeddings</span> <span class="kn">import</span> <span class="n">SentenceTransformerEmbeddings</span>
<span class="kn">from</span> <span class="nn">langchain_chroma</span> <span class="kn">import</span> <span class="n">Chroma</span>

<span class="c1"># Check environment variables for login exist
</span><span class="k">try</span><span class="p">:</span>
	<span class="n">username</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">'BSKY_USERNAME'</span><span class="p">]</span>
	<span class="n">password</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">'BSKY_PASSWORD'</span><span class="p">]</span>
<span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
	<span class="k">raise</span> <span class="nb">RuntimeError</span><span class="p">(</span>
		<span class="s">"Credentials not set in appropriate environment variables!"</span>
	<span class="p">)</span>

<span class="c1"># Login
</span><span class="n">client</span> <span class="o">=</span> <span class="n">Client</span><span class="p">()</span>
<span class="n">profile</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">login</span><span class="p">(</span><span class="n">username</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"Logged in as {}"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">profile</span><span class="p">.</span><span class="n">display_name</span><span class="p">))</span>

<span class="c1"># Get the target timeline
</span><span class="n">target_feed</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">bsky</span><span class="p">.</span><span class="n">feed</span><span class="p">.</span><span class="n">get_timeline</span><span class="p">().</span><span class="n">feed</span> 

<span class="c1"># We should get the latest 50 posts
</span><span class="k">print</span><span class="p">(</span><span class="s">"Feed length: {}"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">target_feed</span><span class="p">)))</span>

<span class="n">posts</span> <span class="o">=</span> <span class="p">[]</span>
  
<span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">target_feed</span><span class="p">:</span>
	<span class="n">uri</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">post</span><span class="p">.</span><span class="n">uri</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">'/'</span><span class="p">)[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
	<span class="c1"># Get the post text
</span>	<span class="n">text</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">post</span><span class="p">.</span><span class="n">record</span><span class="p">.</span><span class="n">text</span>
	
	<span class="k">try</span><span class="p">:</span>
		<span class="c1"># Add descriptions if the post has an external link
</span>		<span class="n">text</span> <span class="o">+=</span> <span class="s">" - "</span> <span class="o">+</span> <span class="n">p</span><span class="p">.</span><span class="n">post</span><span class="p">.</span><span class="n">embed</span><span class="p">.</span><span class="n">external</span><span class="p">.</span><span class="n">description</span>
	<span class="k">except</span><span class="p">:</span>
		<span class="k">pass</span>
	
	<span class="n">posts</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>

<span class="c1"># Embed the posts using a sentence transformer  
</span><span class="n">embedding_model</span> <span class="o">=</span> <span class="n">SentenceTransformer</span><span class="p">(</span>
	<span class="s">'sentence-transformers/all-mpnet-base-v2'</span>
<span class="p">)</span>

<span class="c1"># Create the SentenceTransformerEmbeddings wrapper
</span><span class="n">embedding_function_wrapper</span> <span class="o">=</span> <span class="n">SentenceTransformerEmbeddings</span><span class="p">(</span>
	<span class="n">client</span><span class="o">=</span><span class="n">embedding_model</span>
<span class="p">)</span>

<span class="c1"># Initialise Chroma using the from_texts class method
# Chroma embeds the posts and gives us some nice functions for top-k retrieval
</span><span class="n">vector_db</span> <span class="o">=</span> <span class="n">Chroma</span><span class="p">.</span><span class="n">from_texts</span><span class="p">(</span>
	<span class="n">collection_name</span><span class="o">=</span><span class="s">"post_embeddings"</span><span class="p">,</span>
	<span class="n">texts</span><span class="o">=</span><span class="n">posts</span><span class="p">,</span>
	<span class="n">embedding</span><span class="o">=</span><span class="n">embedding_function_wrapper</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Search!
</span><span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
	<span class="n">query</span> <span class="o">=</span> <span class="nb">input</span><span class="p">(</span><span class="s">"&gt;&gt;"</span><span class="p">)</span>
	<span class="n">res</span> <span class="o">=</span> <span class="n">vector_db</span><span class="p">.</span><span class="n">similarity_search_with_score</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
	
	<span class="c1"># Print the top-k results
</span>	<span class="k">for</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">res</span><span class="p">:</span>
		<span class="k">print</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="n">page_content</span><span class="p">)</span>
		<span class="k">print</span><span class="p">(</span><span class="s">"Score: {:.2f}"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
		<span class="k">print</span><span class="p">(</span><span class="s">"="</span><span class="o">*</span><span class="mi">20</span><span class="p">)</span>
</code></pre></div></div>

<p>For this, we’re using the <a href="https://atproto.blue/en/latest/">AT Protocol SDK</a> to access Bluesky, logging in using a username and password defined in environment variables (<code class="language-plaintext highlighter-rouge">export BSKY_USERNAME="some_username"</code> and <code class="language-plaintext highlighter-rouge">export BSKY_PASSWORD="some_password"</code> when using Ubuntu):</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Check environment variables for login exist
</span><span class="k">try</span><span class="p">:</span>
	<span class="n">username</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">'BSKY_USERNAME'</span><span class="p">]</span>
	<span class="n">password</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">'BSKY_PASSWORD'</span><span class="p">]</span>
<span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
	<span class="k">raise</span> <span class="nb">RuntimeError</span><span class="p">(</span>
		<span class="s">"Credentials not set in appropriate environment variables!"</span>
	<span class="p">)</span>

<span class="c1"># Login
</span><span class="n">client</span> <span class="o">=</span> <span class="n">Client</span><span class="p">()</span>
<span class="n">profile</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">login</span><span class="p">(</span><span class="n">username</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"Logged in as {}"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">profile</span><span class="p">.</span><span class="n">display_name</span><span class="p">))</span>
</code></pre></div></div>
<p>Followed by the processing of posts in the feed, which is fairly self-explanatory once you get the right AT protocol function calls, which are not always easy.</p>

<p>The other interesting part is the core of the search function, which uses embeddings of the posts and the query. We define the embedding model:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Embed the posts using a sentence transformer  
</span><span class="n">embedding_model</span> <span class="o">=</span> <span class="n">SentenceTransformer</span><span class="p">(</span>
	<span class="s">'sentence-transformers/all-mpnet-base-v2'</span>
<span class="p">)</span>

<span class="c1"># Create the SentenceTransformerEmbeddings wrapper
</span><span class="n">embedding_function_wrapper</span> <span class="o">=</span> <span class="n">SentenceTransformerEmbeddings</span><span class="p">(</span>
	<span class="n">client</span><span class="o">=</span><span class="n">embedding_model</span>
<span class="p">)</span>
</code></pre></div></div>
<p>This is a specific model built to embed sentences (<a href="https://huggingface.co/sentence-transformers/all-mpnet-base-v2">all-mpnet-base-v2</a>), which handles a lot of the annoying parts you’d need to implement if you were using a standard LLM, such as chunking the post (so context can be retained across all of the text), and the output being a vector embedding (rather than the generation of new text).</p>

<p>The embeddings are then stored using Chroma:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">vector_db</span> <span class="o">=</span> <span class="n">Chroma</span><span class="p">.</span><span class="n">from_texts</span><span class="p">(</span>
	<span class="n">collection_name</span><span class="o">=</span><span class="s">"post_embeddings"</span><span class="p">,</span>
	<span class="n">texts</span><span class="o">=</span><span class="n">posts</span><span class="p">,</span>
	<span class="n">embedding</span><span class="o">=</span><span class="n">embedding_function_wrapper</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>
<p><a href="https://reference.langchain.com/python/langchain-chroma">Chroma</a> is a vector store, designed for exactly this kind of work. The LLM generates the vector representation of the post, we store it using chroma, and we can leverage built-in functions to determine similarities between the stored posts and query text:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">res</span> <span class="o">=</span> <span class="n">vector_db</span><span class="p">.</span><span class="n">similarity_search_with_score</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</code></pre></div></div>
<p>The <a href="https://reference.langchain.com/python/langchain-chroma/vectorstores/Chroma/similarity_search_with_score">documentation</a> is pretty bad and doesn’t really define the similarity metric or the score. You’d usually use something like cosine similarity when comparing vectors, but high-scoring samples seem to return numbers around 1.5-1.6, which don’t look very cosine.</p>

<p>Anyway, it seems to work okay! The posts that surface from a query are relevant, and on multiple occasions, I’ve been able to find a post I’d been thinking about using a somewhat general query (i.e. not using unusual words that would only appear in that post).</p>

<p>An obvious restriction of this code, as it stands, is that it only takes the most recent 50 posts from your feed. This could quite easily be scaled to search through your entire feed history, but it could take some time to process (embedding each post individually and storing them in Chroma), and while you’re able to access feed histories in Bluesky, I’m not sure how far back they go! But the core methodology of this experiment (LLM post encoding followed by search using Chroma) is built to scale. If we added code to save the vector database to disk and added some feed handling to update the vector store with new posts, we’d have a pretty cool Bluesky search function!</p>]]></content><author><name></name></author><category term="projects" /><category term="ml" /><summary type="html"><![CDATA[The idea is simple. Given a user with their general “Following” feed (the one that collates the posts from everyone you follow), search the feed for information that matches closely with the user input.]]></summary></entry><entry><title type="html">Evolutionary Methods for Generating Secure Deep Neural Networks</title><link href="/projects/ml/2026/04/06/undergrad-diss.html" rel="alternate" type="text/html" title="Evolutionary Methods for Generating Secure Deep Neural Networks" /><published>2026-04-06T12:00:00+00:00</published><updated>2026-04-06T12:00:00+00:00</updated><id>/projects/ml/2026/04/06/undergrad-diss</id><content type="html" xml:base="/projects/ml/2026/04/06/undergrad-diss.html"><![CDATA[<p>This post points people to my undergraduate dissertation, which I submitted in 2019 as part of my CS degree at the University of Birmingham. I hope to write a bit more about it soon, but I just wanted a place where it can live on the internet for the time being. So, click <a href="../../../../../../res/undergrad_dissertation.pdf">here</a> to take a look!</p>]]></content><author><name></name></author><category term="projects" /><category term="ml" /><summary type="html"><![CDATA[This post points people to my undergraduate dissertation, which I submitted in 2019 as part of my CS degree at the University of Birmingham. I hope to write a bit more about it soon, but I just wanted a place where it can live on the internet for the time being. So, click here to take a look!]]></summary></entry><entry><title type="html">letsfoolai - My First Webapp</title><link href="/projects/ml/2026/02/28/letsfoolai.html" rel="alternate" type="text/html" title="letsfoolai - My First Webapp" /><published>2026-02-28T12:00:00+00:00</published><updated>2026-02-28T12:00:00+00:00</updated><id>/projects/ml/2026/02/28/letsfoolai</id><content type="html" xml:base="/projects/ml/2026/02/28/letsfoolai.html"><![CDATA[<p>Welcome! This is a post about my personal project <a href="https://letsfoolai.cloud">letsfoolai.cloud</a>.</p>

<p>This work started as a curiosity about how AI/ML models perform classification, specifically, why they give the answers they do. As humans, we tend to anthropomorphise AI, which makes it surprising when we realise that it has no clue what it’s actually doing. An image classifier lacks semantic understanding of the images it’s classifying, which can lead to some really strange results (see below). By using explainability methods, we can see why a model assigns a class to an image. While most explanations make sense, they also help us create artificial inputs that have no semantic meaning to humans but still lead to highly confident responses from a model (see below).
<img src="res/bad_2.png" alt="A 2 that looks nothing like a 2" /></p>

<p>Partway through this exploration, I thought, <em>“Hey, allowing people to play with this would be cool!”</em> So, this started the project.</p>

<p>I wanted people to be able to visit a web page, provide input to a classifier, and have the classification results returned to them along with an explanation of those results. From there, people could interpret the explanation and maybe use it to fool the model. In addition to providing a fun little website for people to play with, I’d gain experience deploying an AI/ML model in the real world, along with everything needed to make that a reality. What follows is an explanation of how this project works, the design decisions I made along the way, and what I learned in the process!</p>

<h2 id="project-outline">Project Outline</h2>
<p>There are a few parts to this project. We need:
1) An MNIST classifier with explainability.
2) A way to interface with the MNIST classifier from a web page.
3) A way to publish the app so anyone can access it.</p>

<p>The MNIST classifier isn’t much of a problem. This will just be a small convolutional network built with PyTorch. We’ll train the model, store the weights, and then load it at runtime for inference. Since it’s just a small MNIST model, and the input will be a single $28 \times 28$ grayscale image, we don’t need to involve a GPU at inference time. The inclusion of Integrated Gradients for explainability shouldn’t add too much overhead either, so we should be fine with just CPU compute for this project. Of course, if we were handling large images, lots of inputs, or massive models, a GPU would be a necessity, but I’m being very cost-conscious here and don’t want to hammer in a nail with a sledgehammer. To include explainability with the classifications, I used <a href="https://captum.ai/">Captum</a>. A library that simply wraps the PyTorch model, and when given the image we’ve just classified, along with the prediction, it generates an explanation of how the model arrived at its classification. I decided to use the Integrated Gradients<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> approach to model explainability. To compute the integrated gradients of a model $F$ we start with the input to the model $x$ and a baseline $x’$. The baseline is essentially an input that should elicit no meaningful response from the network (i.e., a response as uniform as possible). In the case of images, this would be a black image. We select a dimension $i$ (a pixel in the case of images) and take a path integral of the gradient along a linear interpolation from $x$ to $x’$:</p>

\[IG_i(x) := (x_i - x_i') \cdot \int^1_{\alpha=0} \frac{\partial F (x' + \alpha (x-x'))}{\partial x_i} d\alpha\]

<p>Since $\alpha \in [0,1]$, we gradually move along the path from a completely black image to the original colour version. While this is happening, we’re paying close attention to the rate of change of the model output with respect to $x_i$. This allows us to understand the importance of dimension $i$ to the classification. Doing this for each pixel (or group of pixels) will enable us to see what parts of the input contribute most strongly to the output, producing a heatmap showing how each section contributes to the final class.</p>

<p>Interfacing with the MNIST classifier is an interesting part of this project for me. I’m a classically trained computer scientist, so most of the programs I’ve created in the past have been command-line only. This is often great for efficiency and getting to the core of the problem you’re solving, but it isn’t very accessible to the general public. Because I wanted this to be a web app to exercise my deployment muscle, I knew this would have to be a web page with some JavaScript to make it interactive. I could also use a Python backend to serve the web pages and interact with the model. This was the part I was most cautious about, 1) because I’ve not done much web programming before, and 2) because I’m a security person and know that there are a lot of things that can go wrong security-wise if you’re not careful with your web setup. This is the section I relied on LLMs most heavily. Using them to help me create the JavaScript for the frontend, plug it into the Python backend, and make sure I had addressed most of the obvious security issues. I can’t say the result is perfect, but it does work and I haven’t been hacked (yet, please don’t, if you do find any security issues, please tell me)!</p>

<p>To publish the app, I knew I wanted to use a popular service to gain experience on a well-known platform. I was using Docker for the entire project, so having some way to say: <em>“here’s a Docker container, just make it work”</em> was another aim. I settled on Google Cloud Run, which has the container approach I was looking for, integrated load balancing, good statistics tracking, and is free, provided I don’t exceed a set number of requests. I was a bit disappointed that I didn’t get to do much load balancing because it’s been a while since I’ve done any Kubernetes work, but Google Cloud Run just works, so maybe it’s a good thing not to have another point of failure that I’d have definitely messed up!</p>

<h2 id="mnist-classifier">MNIST Classifier</h2>
<p>Creating the MNIST classifier was fairly straightforward. I used a small architecture I’ve used for a few other projects, consisting of two convolutional layers and one fully connected layer before the output. Since MNIST is such a small dataset, I encountered overfitting issues, so I added some aggressive dropout after the convolution and fully connected layers with a probability of 0.5. The architecture could achieve good results on MNIST (&gt;99%). After training the model weights were saved, and to reduce the project’s attack surface, I removed all training code, leaving only the minimal code to perform inference. The model is loaded and configured to evaluate inputs when the web app starts, so we only load it once per user. Explainability (provided by Captum) is implemented in the backend Python code after classification, with the Captum model initialised after the classifier is loaded from disk.</p>

<p>I admit that the architecture for the classifier is a bit more sophisticated than it needs to be, but I just grabbed a model that I’d previously used for CIFAR classification and wanted to make sure I didn’t run into any capacity issues.</p>

<p>The code for the classifier can be found <a href="https://github.com/dfenth/letsfool-ai/blob/main/letsfool/mnist_model.py">here</a>.</p>
<h3 id="real-inputs-are-never-simple">Real inputs are never simple</h3>
<p>For this section I’ll skip forward a little, to after I implemented the interface. Once I had the web app code in place to provide input to the model, and a strong model trained (10 epochs, learning rate of 1e-4, final validation accuracy of 98.87%, loss of 0.0403, and 99.14% on the test set), I could finally test it end-to-end.
<img src="res/failed_classification_1.png" alt="Model failing to classify data" />
This, unfortunately, didn’t go well. While the model performed as expected on some inputs, on others (as shown above), it failed to classify them correctly in highly perplexing ways. The integrated gradient explainability provides clues. The first image of the 8 (or 9 according to the model) places a negative weight on the bottom left curve of the input, meaning that it is not important to the classification process at all. A similar thing is happening in the bottom image: the left horizontal cross through the centre of the 7 and the line below it are being ignored. This does help to explain why the model is returning these classes (if we remove these sections of the input, we could argue that the 8 would look like a 9 and the 7 would be closest to a 3), but the question is: why is this happening?</p>

<p>One argument I’ll address quickly is that the number of epochs is quite small. So let’s train longer (even though we achieve &gt; 99% accuracy in just 10 epochs). Increasing the number of epochs to 25 (more epochs started to hint at overfitting), I achieved a validation set accuracy of 99.18%, with a loss of 0.0416, and a test set accuracy of 99.19%. 
<img src="res/failed_classification_2.png" alt="Model failing to classify data" />
Still not great! Although we do see greater confidence in attributing parts of the input to the classification.</p>

<p>I thought the architecture might be too complex for such a simple task, so I removed the convolutional layers in favour of a simple fully connected network.
<img src="res/failed_classification_3.png" alt="Model failing to classify data" />
This didn’t work out well. Even though this network is consistently the most confident, and the explanation is very clear where the classification comes from, it’s completely wrong!</p>

<p>What if we revert to the original architecture and add dropout layers to regularise the model? The new model has a validation accuracy of 98.63%, a loss of 0.0498, and a test set accuracy of 98.73%. This seems to work quite well.
<img src="res/failed_classification_4.png" alt="Model failing to classify data" />
But we also have some failures.
<img src="res/failed_classification_5.png" alt="Model failing to classify data" />
<img src="res/failed_classification_6.png" alt="Model failing to classify data" />
The major departures in classification we see appear to come from inputs that don’t use the full space. For example, the first 7 in the images above. The MNIST dataset is a perfect encapsulation of the issue we run into when training models to perform a task and then send them out into the wild. That is: people provide weird inputs. 
<img src="res/strange_inputs_1.png" alt="Some strange inputs" />
7s are tiny, and on the right-hand side of the input space, 3s or 6s are squashed. We need to make the model more robust to the many ways people can squash their numbers into the input space, and the standard MNIST dataset doesn’t do this. The images are perfectly centred, scaled correctly and all straight. To allow the model to generalise to a wider range of inputs, we need to make the training dataset more varied.</p>

<p>Torchvision provides a really quick way to introduce the changes we want to make to the dataset. We use the <code class="language-plaintext highlighter-rouge">RandomAffine</code> transform:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">torchvision</span><span class="p">.</span><span class="n">transforms</span><span class="p">.</span><span class="n">RandomAffine</span><span class="p">(</span><span class="n">degrees</span><span class="o">=</span><span class="mi">30</span><span class="p">,</span> <span class="n">translate</span><span class="o">=</span><span class="p">(</span><span class="mf">0.4</span><span class="p">,</span> <span class="mf">0.4</span><span class="p">),</span> <span class="n">scale</span><span class="o">=</span><span class="p">(</span><span class="mf">0.3</span><span class="p">,</span> <span class="mf">1.7</span><span class="p">))</span>
</code></pre></div></div>
<p>This function allows us to randomise the application of a variety of affine transforms, such as: Rotation (specified with the <code class="language-plaintext highlighter-rouge">degrees</code> parameter), which rotates the input by up to 30 degrees; Translation (<code class="language-plaintext highlighter-rouge">translate</code> parameter), which translates the input by a fraction of the size of the input horizontally or vertically (in this example, we translate the image by up to 40% of it’s size up, down, left, or right); Scale (<code class="language-plaintext highlighter-rouge">scale</code> parameter), which scales the image between 30-170% of its original size.</p>

<p>These transformations occur during dataset loading. This would be more effective if we applied them when passing the input to the model, but the results seem to work well enough without that extra layer of randomness in the training dataset!</p>

<p>After training for 75 epochs, we have a validation accuracy of 80.05%, with a loss of 0.6576, and a testing set accuracy of 80.27%. While we do see a significant drop in the model’s accuracy, it <em>is</em> more robust to unexpected representations of valid inputs.
<img src="res/strange_inputs_2.png" alt="Some strange inputs" />
And on the more general, centred inputs, we were struggling with before:
<img src="res/normal_inputs_1.png" alt="Some normal inputs" />
Seems to work pretty well!
<img src="res/normal_inputs_2.png" alt="Some normal inputs" />
<img src="res/normal_inputs_3.png" alt="Some normal inputs" />
This experience reminds me that you can never be too set in your ways when implementing any computer program or ML model that interfaces with a user. There’s always going to be something surprising that you never really considered. In this case, it was that the standard MNIST dataset is not very representative of the inputs you should expect from a user. Especially if you give them a lot of space to write in!</p>

<p><strong>UPDATE</strong>: I relaxed the transformations (reducing rotation to 15 degrees, and scale to <code class="language-plaintext highlighter-rouge">(0.8, 1.2)</code>), and reduced the model size (using fewer convolution kernels and fully connected units). This new model achieved a validation accuracy of ~93%, while reducing the size of the model on disk from 12.5 MB to 1.6 MB!</p>

<h2 id="building-an-interface">Building an interface</h2>
<p>As I mentioned in the project overview, I didn’t have much experience creating frontends for web apps. I hadn’t really done much work with JavaScript, especially with interacting with users, sending information to a backend, and retrieving something to show on the page afterwards. Because of this, I relied on LLMs to help me get the code right.</p>

<p>I started by defining what the web app should do at a high level. I wanted a small black canvas that visitors could draw on with white ink (reminiscent of the MNIST dataset images). The user should then click a “Classify” button, and the MNIST model should classify the canvas content, returning the class probabilities along with an explanation heat map.</p>

<p>The drawing canvas wasn’t too difficult to get working because people have been drawing on web pages since the beginning of the internet, so a lot of functions already exist to make this easy. Something I hadn’t considered beforehand was adding touch drawing to make the app mobile-friendly. Adding touch drawing just involved a second drawing function, which wasn’t too much extra work. NOTE: <em>The touch implementation is a bit buggy. I really need to look at that again.</em> 😳 The model expects an input image of $28 \times 28$ pixels, but this is a bit small to draw on! To keep things easy to use and process, the drawing canvas is set to $280 \times 280$, and the image is scaled down by a factor of 10 before classification.</p>

<p>Sending the canvas content from the frontend to the backend is done through a <code class="language-plaintext highlighter-rouge">POST</code> request to the <code class="language-plaintext highlighter-rouge">/classify</code> endpoint in the FastAPI Python code. We pause (await) until we receive a JSON response that includes the classification labels, the softmax probabilities for each label, and the explanation heat map. This data is displayed using <a href="https://plotly.com/graphing-libraries/">Plotly</a>, clearly showing the input, prediction, and explanation for the prediction. All of this data is added to the top of the result stack, pushing all other previous classifications further down the page (I was probably more proud of making that stack work than I should have been!).</p>

<p>The code powering the front end can be found in the GitHub project, specifically <a href="https://github.com/dfenth/letsfool-ai/blob/main/letsfool/static/utils.js">here</a> for the JavaScript and <a href="https://github.com/dfenth/letsfool-ai/blob/main/letsfool/static/index.html">here</a> for the HTML. I do not claim that it’s great JavaScript or HTML, but it works.</p>

<p>The backend, acting as a counterpart to the wonderful frontend, was written in Python using the <a href="https://fastapi.tiangolo.com/">FastAPI</a> web framework. There’s nothing too interesting here. On startup, we load the classifier and create the integrated gradients model, both of which persist throughout the application’s lifetime. Classification happens at the <code class="language-plaintext highlighter-rouge">/classify</code> endpoint that takes the image as base64 data, converting it to an image using <a href="https://pillow.readthedocs.io/en/stable/">Pillow</a>. As mentioned earlier, the classification is performed using the previously loaded classifier, with the softmax probabilities, labels, and the explanation heatmap image returned to the frontend.</p>

<p>An interesting part of the backend that I knew I needed to handle, but didn’t know how to approach, was the security of user input to the classifier. Any instance where user input interfaces with code is a security risk, so I wanted to make sure it was handled correctly. So, I asked <a href="https://claude.ai">Claude</a>, which gave this response (imports included for clarity):</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">pydantic</span> <span class="kn">import</span> <span class="n">BaseModel</span><span class="p">,</span> <span class="n">Field</span><span class="p">,</span> <span class="n">field_validator</span>
<span class="kn">import</span> <span class="nn">base64</span>
<span class="kn">import</span> <span class="nn">io</span>
<span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span>

<span class="k">class</span> <span class="nc">ImageRequest</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">image</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">Field</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="n">MAX_PAYLOAD_SIZE</span><span class="p">)</span>

    <span class="o">@</span><span class="n">field_validator</span><span class="p">(</span><span class="s">"image"</span><span class="p">)</span>
    <span class="o">@</span><span class="nb">classmethod</span>
    <span class="k">def</span> <span class="nf">validate_image</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">input_data</span><span class="p">):</span>
        <span class="c1"># Check that it's a PNG data URL (reject anything that isn't)
</span>        <span class="k">if</span> <span class="ow">not</span> <span class="n">input_data</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="s">"data:image/png;base64,"</span><span class="p">):</span>
            <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"User input must be a PNG data URL"</span><span class="p">)</span>

        <span class="c1"># First check passed! Next get the base64 data
</span>        <span class="n">_</span><span class="p">,</span> <span class="n">encoded</span> <span class="o">=</span> <span class="n">input_data</span><span class="p">.</span><span class="n">split</span><span class="p">(</span><span class="s">","</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>

        <span class="c1"># Validate that base64 is well formed
</span>        <span class="k">try</span><span class="p">:</span>
            <span class="n">binary_data</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">encoded</span><span class="p">,</span> <span class="n">validate</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
        <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
            <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Invalid base64 encoding"</span><span class="p">)</span>
        
        <span class="c1"># Validate it's actually a PNG by checking for specific bytes
</span>        <span class="k">if</span> <span class="ow">not</span> <span class="n">binary_data</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="sa">b</span><span class="s">'</span><span class="se">\x89</span><span class="s">PNG</span><span class="se">\r\n\x1a\n</span><span class="s">'</span><span class="p">):</span>
            <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Data is not a valid PNG"</span><span class="p">)</span>
        
        <span class="c1"># Validate dimensions
</span>        <span class="k">try</span><span class="p">:</span>
            <span class="n">image</span> <span class="o">=</span> <span class="n">Image</span><span class="p">.</span><span class="nb">open</span><span class="p">(</span><span class="n">io</span><span class="p">.</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">binary_data</span><span class="p">))</span>
            <span class="n">w</span><span class="p">,</span> <span class="n">h</span> <span class="o">=</span> <span class="n">image</span><span class="p">.</span><span class="n">size</span>
            <span class="k">if</span> <span class="n">w</span> <span class="o">!=</span> <span class="n">MAX_IMAGE_DIM</span> <span class="ow">or</span> <span class="n">h</span> <span class="o">!=</span> <span class="n">MAX_IMAGE_DIM</span><span class="p">:</span>
                <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Image dimensions exceed maximum size {}x{} &gt; {}x{}"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">w</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">MAX_IMAGE_DIM</span><span class="p">,</span> <span class="n">MAX_IMAGE_DIM</span><span class="p">))</span>
            <span class="k">if</span> <span class="n">w</span> <span class="o">&lt;</span> <span class="mi">1</span> <span class="ow">or</span> <span class="n">h</span> <span class="o">&lt;</span> <span class="mi">1</span><span class="p">:</span>
                <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Image too small"</span><span class="p">)</span>
        <span class="k">except</span> <span class="nb">ValueError</span><span class="p">:</span>
            <span class="k">raise</span>
        <span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
            <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span><span class="s">"Could not parse image"</span><span class="p">)</span>

        <span class="k">return</span> <span class="n">input_data</span>
</code></pre></div></div>
<p>The star of the show here is <a href="https://docs.pydantic.dev/latest/">Pydantic</a>, which is a data validation library for Python. We define an <code class="language-plaintext highlighter-rouge">ImageRequest</code> class that inherits from <code class="language-plaintext highlighter-rouge">BaseModel</code> and has a single field: <code class="language-plaintext highlighter-rouge">image</code>. This starts as a string (it’s just a string of bytes), but we make sure that its size does not exceed <code class="language-plaintext highlighter-rouge">MAX_PAYLOAD_SIZE</code>, so we’re not ingesting an input that is larger than we expect, which could grind our poor cloud server to a halt (and quickly exceed our maximum memory capacity).</p>

<p>The validation of the input (the <code class="language-plaintext highlighter-rouge">validate_image</code> function, which has a decorator <code class="language-plaintext highlighter-rouge">@field_validator("image")</code> that I believe connects it to the class field) starts by ensuring the input data is labelled as a PNG. If this is true, the base64-encoded data is decoded into binary, with checks to ensure it’s correctly formatted (<code class="language-plaintext highlighter-rouge">binary_data = base64.b64decode(encoded, validate=True)</code>). But anyone can label any data as a PNG, so we have a check to ensure the first few bytes are consistent with the <a href="https://en.wikipedia.org/wiki/PNG">PNG file format</a> (see the <em>Magic number</em> information in the summary thing on the right, which is consistent with the check: <code class="language-plaintext highlighter-rouge">binary_data.startswith(b'\x89PNG\r\n\x1a\n')</code>). Now that we know the file size is appropriate, it’s valid base64, and it’s actually a PNG, we can decode the bytes into an image, verify the image size matches what we’d expect, and then continue with classification.</p>

<p>The backend can be found in the <code class="language-plaintext highlighter-rouge">main.py</code> file <a href="https://github.com/dfenth/letsfool-ai/blob/main/letsfool/main.py">here</a>.</p>

<h2 id="publishing-the-app">Publishing the app</h2>
<p>To make publishing simple, portable, testable, updateable, etc. I Dockerised the program. I’ve been fairly comfortable with Docker for a while, so I knew it was the standard way to build apps that run on the internet. I did extend my knowledge with the build stages, though. As Claude pointed out, leaving all the installs I needed to install the Python libraries in the deployed container would create a larger attack surface than necessary. By building the libraries into a wheel file and passing it from the build to the runtime container, I could make the deployed container more secure and smaller. I also followed best practices to limit user permissions within the container (just in case someone broke out of the program), so that a potential adversary would not have superuser privileges.</p>

<p>The main application was served from the Docker container using <a href="https://gunicorn.org/">Gunicorn</a>, which has lots of options for the number of workers, timeouts, etc. This was bound to a port allowing the app to be served via the same exposed container port. The Dockerfile is <a href="https://github.com/dfenth/letsfool-ai/blob/main/Dockerfile">here</a>.</p>

<p>To deploy the Docker container, I used <a href="https://cloud.google.com/run">Google Cloud Run</a>. This service had a number of attractive selling points, including: autoscaling - so the number of containers that are deployed can grow or shrink depending on demand (there won’t be much), it can automatically build containers from GitHub - so I can have continuous integration (CI) and continuous delivery/deployment (CD), and it handles the TLS/SSL certificate - so my app can use HTTPS. In addition, Cloud Run has access to GPU compute and large-scale storage (external volume mounting), should I ever need to expand the app with GPU-accelerated computing or massive datasets/models in the future.</p>

<p>I kept the setup fairly conservative because this is just a test app, and I don’t expect it to be a massive service that gets lots of traffic:</p>
<ul>
  <li>I set the minimum number of instances to 0, so if the app doesn’t need to run, it won’t! This also cuts down on costs because I won’t have a container constantly running just in case someone visits. I’m not too worried about cold starts. It does take a bit of time for the app to load sometimes, but that’s a trade-off I’m willing to make.</li>
  <li>The maximum number of instances is 3. I don’t expect many people to access this at once, or at all, so keeping the maximum number low lets me control costs better and make sure expenses don’t get out of hand.</li>
  <li>I gave each instance 512 MiB of memory, and from the service metrics, container memory utilisation is around 80%, so this was a good call! It’s nice to see there’s not much wastage there.</li>
  <li>Each instance has 1 virtual CPU because it doesn’t really need much more. It’s fairly lightweight and performs well.</li>
</ul>

<p>For the CI/CD portion of this project, I hooked up my <a href="https://github.com/dfenth/letsfool-ai">GitHub repo</a> to the cloud service to automate the development and deployment process. Cloud Run allows you to add triggers, so when new data is pushed to the repo the Docker container is automatically built, checked and deployed to the service, which streamlines the workflow massively.</p>

<h2 id="conclusion">Conclusion</h2>
<p>So, I have a web app now! You can check it out at <a href="https://letsfoolai.cloud">letsfoolai.cloud</a>. You can read a bit about how to fool a classifier and then try it out yourself! I don’t expect this to be a very popular app (it’s not that interesting), but I’m so happy it all came together, and it works!</p>

<p>This has been such a learning experience. From the classifier to the web server to the deployment, I’ve had to force myself out of my programming comfort zone to do something new. It’s been a headache at times, primarily in deployment, because that is something that I have no experience with, and understanding all the steps I had to go through to deploy containers and then bug fix what went wrong with very in-depth service analysis pages (I had issues with log file permissions, errors in container deployment due to too little memory, etc.). It has been tough, but so worthwhile! At least now I have a small idea about how modern applications are made available to the masses, and I will sympathise with the engineers if a well-known service is down. 😆</p>

<h2 id="update-on-cicd">Update on CI/CD</h2>
<p>So, it turns out that when I pushed new updates to the repo, they didn’t translate into new builds being deployed by Cloud Run. The culprit was the <code class="language-plaintext highlighter-rouge">cloudbuild.yaml</code> file. It wasn’t set up correctly to deploy automatically after a new build was created, so I had to do a manual deploy by opening the deploy options and selecting the build with the correct commit hash. I think this has been fixed with an updated yaml file:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">steps</span><span class="pi">:</span>
<span class="c1"># Build image</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">gcr.io/cloud-builders/docker'</span>
  <span class="na">args</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">build'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">-t'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">gcr.io/${PROJECT_ID}/letsfool-ai:${COMMIT_SHA}'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">.'</span><span class="pi">]</span>
<span class="c1"># Push image to artefact registry</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">gcr.io/cloud-builders/docker'</span>
  <span class="na">args</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">push'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">gcr.io/${PROJECT_ID}/letsfool-ai:${COMMIT_SHA}'</span><span class="pi">]</span>
<span class="c1"># Deploy to cloud run</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">gcr.io/google.com/cloudsdktool/cloud-sdk'</span>
  <span class="na">entrypoint</span><span class="pi">:</span> <span class="s">gcloud</span>
  <span class="na">args</span><span class="pi">:</span> <span class="pi">[</span>
    <span class="s1">'</span><span class="s">run'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">deploy'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">letsfool-ai'</span><span class="pi">,</span>
    <span class="s1">'</span><span class="s">--image'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">gcr.io/${PROJECT_ID}/letsfool-ai:${COMMIT_SHA}'</span><span class="pi">,</span>
    <span class="s1">'</span><span class="s">--region'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">europe-west1'</span><span class="pi">,</span>
    <span class="s1">'</span><span class="s">--platform'</span><span class="pi">,</span> <span class="s1">'</span><span class="s">managed'</span>
  <span class="pi">]</span>

<span class="na">images</span><span class="pi">:</span>
<span class="pi">-</span> <span class="s1">'</span><span class="s">gcr.io/${PROJECT_ID}/letsfool-ai:${COMMIT_SHA}'</span>
<span class="na">options</span><span class="pi">:</span>
  <span class="na">logging</span><span class="pi">:</span> <span class="s">CLOUD_LOGGING_ONLY</span>
</code></pre></div></div>
<p>We now have separate build and deploy stages, so when I push to the repo, the new container is automatically built and then deployed via Google Cloud Run.</p>

<hr />
<h1 id="references">References</h1>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p><a href="https://arxiv.org/abs/1703.01365">Axiomatic Attribution for Deep Networks</a> - Sundararajan, Taly, Yan (2017) <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><category term="projects" /><category term="ml" /><summary type="html"><![CDATA[Welcome! This is a post about my personal project letsfoolai.cloud.]]></summary></entry><entry><title type="html">Netflix Video Encoding Quality Metric</title><link href="/research/paper/video/2026/02/24/vmaf.html" rel="alternate" type="text/html" title="Netflix Video Encoding Quality Metric" /><published>2026-02-24T12:00:00+00:00</published><updated>2026-02-24T12:00:00+00:00</updated><id>/research/paper/video/2026/02/24/vmaf</id><content type="html" xml:base="/research/paper/video/2026/02/24/vmaf.html"><![CDATA[<p>These notes are based on a blog post titled: <a href="https://netflixtechblog.com/toward-a-practical-perceptual-video-quality-metric-653f208b9652">Toward A Practical Perceptual Video Quality Metric</a> by Zhi Li, Anne Aaron, Ioannis Katsavounidis, Anush Moorthy and Megha Manohara from Netflix.</p>

<hr />
<p>Netflix cares about video quality (of course). This post from 2016 details how they created an encoding quality check that more closely aligns with human perception, which is strategically deployed to assess the quality of videos being served to consumers throughout their vast content delivery network. The authors state that this is to evaluate any changes they make to the streaming method, e.g., encoding algorithms, to ensure customers receive high-quality service, which is something I never really considered. So that’s pretty cool.</p>

<p>All of these checks rest on an accurate method for determining the perceptual quality of a video that can be deployed at scale. While human visual perception is the gold standard (humans can quite easily tell if a video stream is low-quality), it’s not very scalable and is difficult to automate in the Netflix content delivery network. So we need to fall back on automated processes to evaluate video quality. Historically, methods such as Mean Squared Error (<a href="https://en.wikipedia.org/wiki/Mean_squared_error">MSE</a>), Peak signal-to-noise ratio (<a href="https://en.wikipedia.org/wiki/Peak_signal-to-noise_ratio">PSNR</a>), and Structural Similarity Index Measure (<a href="https://en.wikipedia.org/wiki/Structural_similarity_index_measure">SSIM</a>) have been used, but fail to capture the quality of a video encoding from a human perspective.</p>

<blockquote>
  <p>The aim of this work is to find a correlation between human perception of video quality and the image statistics we can extract.</p>
</blockquote>

<p>The authors create a new dataset of 6-second video clips from a range of content available on Netflix and from publicly available sources (animation, videos with film grain, camera motion, indoor/outdoor environments, close-ups of faces, etc.), with a range of resolutions (up to 1920x1080) and bitrates. They then had human observers view each video clip and rate the encoding impairment on a scale from 1 (very annoying) to 5 (not noticeable). The scores from all observers were combined, creating a Differential Mean Opinion Score (<a href="https://en.wikipedia.org/wiki/Mean_opinion_score">DMOS</a>), normalised in the range 0 to 100 (where 100 is the score of the reference image). The collection of videos and DMOS is the NFLX Video Dataset. The dataset is (unsurprisingly) not publicly available (as far as I’m aware). Another interesting point is that they only used H.264/AVC codec for the tests.</p>

<p>The authors found that the results from PSNR, SSIM, Multiscale FastSSIM and PSNR-HSV showed little correlation with the human DMOS scores. Even more interesting is that different types of content exhibit different relationships between the quantitative metric and the DMOS score, with small degradations in any quantitative metric leading to a noticeable drop in the DMOS score.</p>

<p>To align the DMOS scores with a quantitative metric, the authors propose an ML-based approach, Video Multimethod Assessment Fusion (VMAF), that predicts subjective quality from multiple elementary quality metrics. The rationale is that each elementary quality metric has strengths and weaknesses, based on the types of artefacts and degree of distortion. If we combine a whole bunch of different approaches, we should have something better, right?</p>

<p>The authors fuse the following elementary metrics using SVM regression:</p>
<ul>
  <li>Visual Information Fidelity (<a href="https://ieeexplore.ieee.org/document/1576816">VIF</a>) - Image quality metric based on the idea that quality is complementary to the measure of information fidelity loss. Fidelity loss is measured on four scales (a distortion model consisting of blur, additive noise, and global or local contrast changes, I think), with VMAF including each scale as an elementary metric. These are quantified using spectral methods (I believe).</li>
  <li>Detail Loss Metric (<a href="https://ieeexplore.ieee.org/document/5765502">DLM</a>) - Image quality metric based on the idea that we can separately measure the loss of details that impact content visibility. This decouples distortions into additive impairments (redundant visual information that does not exist in the original image, e.g. encoding artefacts) and detail losses (loss of useful visual information). Both of these can be quantified by extracting information from the spectral domain (as I understand it).</li>
  <li>Motion - A measure of the temporal difference between adjacent frames, calculated by finding the average absolute pixel difference for the luminance component.</li>
</ul>

<p>They then train VMAF on the NFLX Training dataset and report results on the testing dataset. The results look convincing. There’s a strong $y=x$ linear correlation between the predicted VMAF and DMOS scores. Compared to the existing Video Quality Model with Variable Frame Delay (<a href="https://its.ntia.gov/publications/download/11-482.pdf">VQM-VFD</a>), it beats it in nearly all tests (and is even stronger when VQM-VFD is used to augment it).</p>

<p>This work is available on GitHub - <a href="https://github.com/Netflix/vmaf?tab=readme-ov-file">VMAF Development Kit (VDK 1.0.0)</a>.</p>]]></content><author><name></name></author><category term="research" /><category term="paper" /><category term="video" /><summary type="html"><![CDATA[These notes are based on a blog post titled: Toward A Practical Perceptual Video Quality Metric by Zhi Li, Anne Aaron, Ioannis Katsavounidis, Anush Moorthy and Megha Manohara from Netflix.]]></summary></entry><entry><title type="html">Graph Embedding for Malware Detection - A failed paper</title><link href="/research/gnn/malware/2026/01/21/GEMD-paper.html" rel="alternate" type="text/html" title="Graph Embedding for Malware Detection - A failed paper" /><published>2026-01-21T17:00:00+00:00</published><updated>2026-01-21T17:00:00+00:00</updated><id>/research/gnn/malware/2026/01/21/GEMD-paper</id><content type="html" xml:base="/research/gnn/malware/2026/01/21/GEMD-paper.html"><![CDATA[<p>⚠️⚠️⚠️</p>
<blockquote>
  <p>This piece of work was done fairly early on in my PhD. This was back when I was focusing on Geometric Deep Learning and trying to change the world of malware detection with some novel approaches to application representation. The gist of what I wanted to do was to break an app down into basic-blocks and analyse the geometry of the code graph. My feeling was that there may be some unique geometry associated with malware that we can detect, and due to this being a more fundamental part of the structure of a program, it may be more resilient to concept drift. This didn’t end up working out. I worked on this in years 2 and 3 of my PhD and with about a year left to write my thesis I had a massive training pipeline and an AI model that took about a month to train. The approach wasn’t working and I didn’t know which part was the problem. With bugs to fix and only a handful of opportunities to actually train the AI model, I would have been under a lot of pressure to get something working for my thesis (if it was even possible). I decided to cut my losses and change direction. I’ll be making a series of posts about this failed project. Even though it didn’t work, I’m still really proud of what I produced.</p>
</blockquote>

<p>⚠️⚠️⚠️</p>

<hr />

<h2 id="abstract">Abstract</h2>

<p>The development of malware detection methods in the Android ecosystem has benefited from a huge amount of research in the past few years. Many Machine Learning based approaches have reported incredible performance, but are challenged by real world deployment for two primary reasons. The first is that they are susceptible to dataset drift, occuring as malware evolves to avoid detection which reduces the model performance as a function of time. The second is the lack of transparency in the classifications which inhibits their use in safety and security critical applications.</p>

<p>In this paper we attempt to tackle these two concerns using Graph Neural Network based classifiers and Explainable AI techniques. While there have been many papers proposing Graph Neural Networks as malware classifiers, none have explored the robustness of them to dataset drift, a crucial component to determining their ability to operate effectively in the real world. To explore this point we analyse the ability of Graph Neural Network based classifiers to retain high classification values in the presence of dataset drift. We also explore the use of Explainable AI in analysing the salient graph features which contribute to classification. This provides deep insights into characteristics of malware by providing human interpretable features which increase the verifiability of the classification model and thus the trustworthyness in safety and security critical applications.</p>

<hr />

<h2 id="introduction">Introduction</h2>

<p>The Android Operating System (OS) and its many variants are collectively the most popular in the world accounting for approximately 43% of all computer devices<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>. This popularity has naturally made it a prime target for malware as adversaries attempt to extract personal information from its many users. Malware typically infects Android devices through the user downloading a malicious application (app) from one of the many app stores available. This has led to a wealth of research focused on classifying apps as benign or malicious based on their properties. Many of these classifiers report high $F_1$, precision and recall values which appears to indicate that this is a solved problem. However, work conducted by Pendlebury et al.<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> has shown that the reported $F_1$, precision, and recall values often do not tell the full story.</p>

<p>All malware classifiers will suffer from dataset drift. Dataset drift captures many different shifts that can occur in the dataset over time, but it encapsulates the idea that as time progresses the data a classifier is expected to operate on becomes further from the distribution of data it is trained on. Concept drift is of particular interest to us due to the nature of malware, where malicious programs are constantly changing and evolving to avoid detection by classifiers. Testing classifiers in the presence of concept drift is a key evaluation metric which is missing from many of the proposed malware classification methods which presents challenges when it comes to deploying the models in a real world situation.</p>

<p>Approaches to malware classification in the Machine Learning (ML) community have focused on extracting certain properties of the apps which the authors believe would be useful to the categorisation task. Classifiers such as Drebin<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> create feature vectors for each app consisting of metadata such as requested hardware components and permissions along with code information such as suspicious API calls and network addresses. Competing ideas in the malware classification space come from Graph Neural Networks (GNNs). Methods such as MAGIC<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup> and MalGraph<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup> argue that we can create more powerful classifiers by representing programs as graphs and use the resulting structure as a feature in addition to features that can be extracted from the code itself. Programs have many natural graph representations, such as Function Call Graphs (FCGs), Control Flow Graphs (CFGs) and Data Flow Graphs (DFGs), therefore this approach to classification has gained traction in the malware detection community. The results reported by many of the GNN malware detection papers show performance competitive with the non-graph variants, however, there is (to our knowledge) no evaluation of GNN methods with respect to concept drift.</p>

<p>We can intuitively reason that GNN approaches to malware classification may be more robust than non-GNN approaches by considering the data we use for classification. Non-GNN classifiers rely on app features which are generally more susceptible to changes in time. For instance both Drebin<sup id="fnref:3:1" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> and MaMaDroid<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup> create feature vectors based on API calls (or families of API calls) which can change over time as Android updates the APIs available to developers. On the other hand while GNN methods include API information, this is often augmented by structure information in the form of the program graphs. It is less likely that these graph structures will display significant changes over time which may make them more robust to concept drift.</p>

<p>Explainable Articificial Intelligence (XAI) is a tool that can be used to increase model transparency by highlighting features of the input that are salient to the classification. In the field of malware research, where the safety and security of the system are highly important, this can increase the confidence that end-users have in the model, allowing results to be verified in that we can ensure that the features that are being used for classification are appropriate. In this paper we use an implementation of SubgraphX<sup id="fnref:7" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup> to inspect the features that are being used for malware classification which are (hopefully) predominantly subgraph structures. This (hopefully) supports our hypothesis that subgraph structures can be used as robust and reliable features which are characteristic of malware.</p>

<p><em>Our contributions</em>: This paper evaluates the effect that concept drift has on GNN based malware classifiers in the Android ecosystem. We (hopefully) show that GNN classifiers are more robust to the effects of concept drift, retaining high scores for many months that succeed the training data. We use SubgraphX<sup id="fnref:7:1" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup> to confirm that this behaviour is due to the effective use of the program graph structure which augments the classification process with rich data which is slower to change compared to specific code based features (such as API usage, URL schemes etc.) which are predominantly used by non-GNN based malware classifiers.</p>

<hr />

<h2 id="related-work">Related work</h2>

<h3 id="malware-classification">Malware Classification</h3>

<p>The automated detection of malware using ML methods has been an area of active research for many years, particularly in the space of Android applications. Popular Android malware classifiers such as Drebin<sup id="fnref:3:2" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup> and MaMaDroid<sup id="fnref:6:1" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup> apply traditional ML techniques to feature vectors they extract from apps. Drebin focuses on collecting features such as requested hardware access, system permissions, inter/intra-process communications and interfaces to the OS along with restricted API calls and app permissions. This information is then embedded and classified using a Support Vector Machine (SVM). MaMaDroid considers the app program graph in the form of a package or family API call graph extracted from the program binary. The call graph is used to create a Markov chain which models the probability of a transition occurring between API calls. This Markov chain is then used to create a feature vector where entries are the probability of transitioning between the API calls. This is then reduced using Principal Component Analysis (PCA) to an embedding space for classification.</p>

<p>These approaches produced strong $F_1$, precision and recall values, however, they were later shown by Pendlebury et al.<sup id="fnref:2:1" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> to suffer from concept drift which showed that their effectiveness in real-world applications is limited. This finding could be due to the reliance of these methods on API call analysis which can be volatile between OS updates.</p>

<p>GNNs have been gaining in popularity in the malware classification space, primarily because of their expressive nature of their inputs which allows us to leverage structure of data in the classification process. The most intuitive representation of program data is a program graph which was leveraged by a paper proposing MAGIC<sup id="fnref:4:1" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>. MAGIC focused on classifying benign and malicious programs from Windows binaries using GNNs. The graph representation of the binaries are created by decompiling the programs into assembly, then creating basic-blocks from the instructions. The graph consists of a basic-block as the graph node which contains summary information about the instructions contained within them, with edges representing the connectivity between the basic-blocks in the code structure. This graph is then fed into a Deep Graph Convolutional Neural Network (DGCNN)<sup id="fnref:8" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup> with graph reduction layers such as SortPool<sup id="fnref:8:1" role="doc-noteref"><a href="#fn:8" class="footnote" rel="footnote">8</a></sup> before classification which consists of a multi-layer perceptron.</p>

<p>A different approach to the Graph based classification method, which demonstrates the expressive nature of the technology, can be seen in a paper which introduces MalGraph<sup id="fnref:5:1" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>. MalGraph uses a hierarchical graph representation to perform classification using GNNs. At the high level of the hierarchy MalGraph defines an inter-function FCG which details the high level function interactions, and the lower level hierarchy is populated with intra-function CFGs of local functions. This ultimately creates a multi-modal graph where nodes can be local functions which have been processed by a GNN resulting in an embedding to a local function space, or an external function node which is simply one-hot encoded based on the function name. The multi modal FCG is passed to another GNN, followed by a multi-layer perceptron for classification.</p>

<p>One recent application of these graph methods to the Android malware space was presented in a paper by Hei et al. which proposes Hawk<sup id="fnref:9" role="doc-noteref"><a href="#fn:9" class="footnote" rel="footnote">9</a></sup>. Hawk uses a Heterogeneous Information Network (HIN) which creates a graph of heterogeneous nodes from features such as program APIs, permissions, interfaces etc. with apps being added to the graph with connectivity based on the features they have (e.g. an app node will be connected to a permission node if an app has that permission). A homogeneous graph is then extracted which just contains the app nodes. This is then passed to a variant of the Graph Attention Network (GAT)<sup id="fnref:10" role="doc-noteref"><a href="#fn:10" class="footnote" rel="footnote">10</a></sup> for classification.</p>

<p>While the graph approaches detailed here all report a strong ability to differentiate malware from goodware on their datasets, there has been (to our knowledge) no thorough analaysis of these methods with respect to concept drift, an issue this paper hopes to change.</p>

<h3 id="explainable-ai">Explainable AI</h3>

<p>XAI is an invaluable technology in situations where safety and security are of critical importance. In this paper XAI is used to determine features which are salient in the malware classification process. Several papers address the application of XAI to GNNs such as GNNExplainer<sup id="fnref:11" role="doc-noteref"><a href="#fn:11" class="footnote" rel="footnote">11</a></sup>, GNN-GI and GNN-LRP<sup id="fnref:12" role="doc-noteref"><a href="#fn:12" class="footnote" rel="footnote">12</a></sup> and PGExplainer<sup id="fnref:13" role="doc-noteref"><a href="#fn:13" class="footnote" rel="footnote">13</a></sup> which all focus on structural node, edge, or node feature explanations of the graphs. Our focus will be on SubgraphX<sup id="fnref:7:2" role="doc-noteref"><a href="#fn:7" class="footnote" rel="footnote">7</a></sup> which specifically uses subgraphs for explanation which, the authors argue, provide a more human interpretable and useful explanation of the classifications.</p>

<p>The application of XAI methods to Graph-based malware classification has been carried out by Herath et al.<sup id="fnref:14" role="doc-noteref"><a href="#fn:14" class="footnote" rel="footnote">14</a></sup> with CFGExplainer.</p>

<hr />

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Source: <a href="https://gs.statcounter.com/os-market-share">statcounter (April 2022)</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Pendlebury et al. (2019) <a href="https://www.usenix.org/system/files/sec19fall_pendlebury_prepub.pdf">TESSERACT: Eliminating Experimental Bias in Malware Classification across Space and Time</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:2:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>Arp et al. (2014) <a href="https://media.telefonicatech.com/telefonicatech/uploads/2021/1/4915_2014-ndss.pdf">DREBIN: Effective and Explainable Detection of Android Malware in Your Pocket</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:3:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:3:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p>Yan et al. (2019) <a href="https://drive.google.com/file/d/1gR_sa61DN3SkwTi2PC7B9V0ipofis3tU/view">Classifying Malware Represented as Control Flow Graphs using Deep Graph Convolutional Neural Network</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:4:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>Ling et al. (2022) <a href="https://ieeexplore.ieee.org/document/9796786">MalGraph: Hierarchical Graph Neural Networks for Robust Windows Malware Detection</a> <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:5:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p>Mariconti et al. (2016) <a href="https://arxiv.org/pdf/1612.04433">MaMaDroid: Detecting Android Malware by Building Markov Chains of Behavioural Models</a> <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:6:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:7" role="doc-endnote">
      <p>Yuan et al. (2021) <a href="https://arxiv.org/pdf/2102.05152">On Explainability of Graph Neural Networks via Subgraph Explorations</a> <a href="#fnref:7" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:7:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a> <a href="#fnref:7:2" class="reversefootnote" role="doc-backlink">&#8617;<sup>3</sup></a></p>
    </li>
    <li id="fn:8" role="doc-endnote">
      <p>Zhang et al. (2018) <a href="https://ojs.aaai.org/index.php/AAAI/article/view/11782">An End-to-End Deep Learning Architecture for Graph Classification</a> <a href="#fnref:8" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:8:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:9" role="doc-endnote">
      <p>Hei et al. (2024) <a href="https://ieeexplore.ieee.org/document/9524453">Hawk: Rapid Android Malware Detection Through Heterogeneous Graph Attention Networks</a> <a href="#fnref:9" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:10" role="doc-endnote">
      <p>Veliˇckovi´c et al. (2018) <a href="https://arxiv.org/pdf/1710.10903">Graph Attention Networks</a> <a href="#fnref:10" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:11" role="doc-endnote">
      <p>Ying et al. (2019) <a href="https://proceedings.neurips.cc/paper_files/paper/2019/file/d80b7040b773199015de6d3b4293c8ff-Paper.pdf">GNNExplainer: Generating Explanations for Graph Neural Networks</a> <a href="#fnref:11" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:12" role="doc-endnote">
      <p>Schnake et al. (2022) <a href="https://ieeexplore.ieee.org/stamp/stamp.jsp?arnumber=9547794">Higher-Order Explanations of Graph Neural Networks via Relevant Walks</a> <a href="#fnref:12" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:13" role="doc-endnote">
      <p>Luo et al. (2020) <a href="https://proceedings.neurips.cc/paper/2020/file/e37b08dd3015330dcbb5d6663667b8b8-Paper.pdf">Parameterized Explainer for Graph Neural Networks</a> <a href="#fnref:13" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:14" role="doc-endnote">
      <p>Herath et al. (2022) <a href="https://ieeexplore.ieee.org/document/9833560">CFGExplainer: Explaining Graph Neural Network-Based Malware Classification from Control Flow Graphs</a> <a href="#fnref:14" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><category term="research" /><category term="gnn" /><category term="malware" /><summary type="html"><![CDATA[⚠️⚠️⚠️ This piece of work was done fairly early on in my PhD. This was back when I was focusing on Geometric Deep Learning and trying to change the world of malware detection with some novel approaches to application representation. The gist of what I wanted to do was to break an app down into basic-blocks and analyse the geometry of the code graph. My feeling was that there may be some unique geometry associated with malware that we can detect, and due to this being a more fundamental part of the structure of a program, it may be more resilient to concept drift. This didn’t end up working out. I worked on this in years 2 and 3 of my PhD and with about a year left to write my thesis I had a massive training pipeline and an AI model that took about a month to train. The approach wasn’t working and I didn’t know which part was the problem. With bugs to fix and only a handful of opportunities to actually train the AI model, I would have been under a lot of pressure to get something working for my thesis (if it was even possible). I decided to cut my losses and change direction. I’ll be making a series of posts about this failed project. Even though it didn’t work, I’m still really proud of what I produced.]]></summary></entry><entry><title type="html">Old notes on Graph Neural Networks</title><link href="/research/notes/gnn/2026/01/20/old-notes-on-gnns.html" rel="alternate" type="text/html" title="Old notes on Graph Neural Networks" /><published>2026-01-20T17:00:00+00:00</published><updated>2026-01-20T17:00:00+00:00</updated><id>/research/notes/gnn/2026/01/20/old-notes-on-gnns</id><content type="html" xml:base="/research/notes/gnn/2026/01/20/old-notes-on-gnns.html"><![CDATA[<p>Graph Neural Networks can be expressed most simply as:</p>

<p>$Z = \hat{D}^{-1/2}\hat{A}\hat{D}^{-1/2}XW+b$</p>

<p>Where:</p>

<p>$\hat{A} = A+I$, $\hat{A} \in \mathbb{R}^{N\times N}$ is the adjacency matrix ($N$ is the number of graph vertices/nodes)</p>

<p>$\hat{D}_{ii}=\sum_j^N \hat{A}_{ij}$, $\hat{D} \in \mathbb{R}^{N \times N}$ is the degree matrix</p>

<p>$X \in \mathbb{R}^{N\times F}$ is the feature matrix ($F$ is the number of features we have for each vertex)</p>

<p>$W \in \mathbb{R}^{F\times C}$ is the learned weight matrix ($C$ is the number of weight channels we choose to have)</p>

<p>$b \in \mathbb{R}^{F \times C}$ is the learned bias matrix</p>

<p>Note that the weight and bias part of the above equation is often shortened to $\Theta$ were $\Theta = W+b$.</p>

<p>We’re likely to want to chain multiple graph convolutions together in a ML system, so we re-write the equation above as:</p>

<p>$Z^{(l+1)} = \sigma \left( \hat{D}^{-1/2}\hat{A}\hat{D}^{-1/2}Z^{(l)}\Theta^{(l)} \right)$</p>

<p>Where $l$ denotes the layer and $\sigma$ is a non-linear activation function (usually a sigmoid or ReLU) and $Z^{(0)} = X$.</p>

<p>What we can see here is that each layer of the graph convolution returns another value of $X$, which means we’re operating over the features of the vertices of the graphs. The inclusion of  $\hat{D}^{-1/2}\hat{A}\hat{D}^{-1/2}$ shows we’re using some normalised information from the topology of the network to influence this convolution, with this all being weighted by the learned parameters $\Theta$. It’s important to note that $Z$ changes shape as the convolution continues. Initially since $Z^{(0)} = X$, $Z^{(0)} \in \mathbb{R}^{N \times F}$ and later as we multiply by $\Theta \in \mathbb{R}^{F \times C}$ then $Z^{(l)} \in \mathbb{R}^{N \times C}$ which is a matrix of size defined by the number of vertices by the number of weight kernels (each column can be thought of as a way of assigning a value to each node in the graph). $W^{(0)} \in \mathbb{R}^{F \times H}$ (an input to hidden layer with $H$ feature maps) with $W^{(1)} \in \mathbb{R}^{H \times C}$.</p>

<p>Practically, since $\hat{D}^{-1/2}\hat{A}\hat{D}^{-1/2}$ is a constant for graph convolution we can calculate it once and forget about it: $\tilde{A} = \hat{D}^{-1/2}\hat{A}\hat{D}^{-1/2}$</p>

<p>We can then reduce the above equation to a much cleaner form:</p>

<p>$Z^{(l+1)} = \sigma \left( \tilde{A}Z^{(l)}\Theta^{(l)} \right)$</p>

<p>We can easily see how they’ll all chain together:</p>

<p>$Z = \sigma^{(L)}(\dots\sigma^{(1)}(\tilde{A} \sigma^{(0)}(\tilde{A}X\Theta^{(0)})\Theta^{(1)})\dots\Theta^{(L)})$</p>

<h3 id="looking-at-theta">Looking at $\Theta$</h3>

<p>Since $\Theta \in \mathbb{R}^{F \times C}$ then it doesn’t depend on $N$ (the number of vertices). In fact, it doesn’t rely on a specific graph topology at all! This means that we have a graph invariant parameter (which was the focus of much of the early GNN research) which means we can use this learned parameter on many different graphs. This gives credibility to the idea that we can change both the graph and the graph data every training example and still be able to learn a $\Theta$ that can be useful to us!</p>]]></content><author><name></name></author><category term="research" /><category term="notes" /><category term="gnn" /><summary type="html"><![CDATA[Graph Neural Networks can be expressed most simply as:]]></summary></entry><entry><title type="html">Hacking Pokemon for R(ea)L fun</title><link href="/research/ai/rl/2026/01/14/hacking-pkm.html" rel="alternate" type="text/html" title="Hacking Pokemon for R(ea)L fun" /><published>2026-01-14T15:00:00+00:00</published><updated>2026-01-14T15:00:00+00:00</updated><id>/research/ai/rl/2026/01/14/hacking-pkm</id><content type="html" xml:base="/research/ai/rl/2026/01/14/hacking-pkm.html"><![CDATA[<style>
video {
  width: 100%;
  max-width: 800px;
  height: auto;
}
</style>

<p>My first real mind-blowing experience with AI came from DeepMind’s <a href="https://arxiv.org/abs/1312.5602">Atari Deep Q-learning agent</a> way back in 2013! Seeing a computer gradually learn to become proficient at a game and then surpass human experts really captured my imagination and was the catalyst for my investment (through education and research) in AI. Since that time, the AI space has exploded with breakthroughs and investment, but with the renewed interest, I feel like the space has lost some of the magic.</p>

<p>In an attempt to find that spark again, I thought I’d embark on a project that aims to be nothing but an interesting exercise for my own amusement. I’ve always been interested in Reinforcement Learning (RL), but haven’t had many opportunities to work in the space (my research is mostly in computer vision/neural network fundamentals), so, to honour DeepMind’s Atari agent, I thought I’d make a computer play a game.</p>

<h2 id="setup">Setup</h2>
<p>The game in question is <a href="https://en.wikipedia.org/wiki/Pok%C3%A9mon_Crystal">Pokémon Crystal</a>. If you’re unfamiliar with the Pokémon games, you control a character who wanders around a large (mostly linear) world, collecting and battling Pokémon. You collect Pokémon by walking through tall grass in the world, which can trigger random battles with wild Pokémon. In these battles, you can throw Pokéballs which have a percentage chance of capturing the Pokémon you’re battling against (with the percentage dependent on the amount of health the wild Pokémon has, along with multiple other factors). If you successfully capture the Pokémon, you can use it in subsequent battles. Game progress is blocked by ‘trainer battles’, which are battles against other Pokémon trainers. The aim of the game is to beat the 8 gym leaders (more powerful trainers), get the 8 gym badges, then beat the elite 4 (even more powerful trainers), and finally the champion (the most powerful trainer). There are some side stories along the way as well, but that’s the basic premise. So, from an AI perspective, the game can be split into two:</p>

<ol>
  <li>Navigation around the world (knowing/finding out where to go next)</li>
  <li>Beating trainers in Pokémon battles</li>
</ol>

<p>A few years ago, Peter Whidden published a <a href="https://www.youtube.com/watch?v=DcYLT37ImBY">YouTube video</a> where he <a href="https://github.com/PWhiddy/PokemonRedExperiments">trained an RL agent to play Pokémon Red</a>. This is a seriously impressive project that addresses the two halves of the game, with the agent both navigating the world and being competent enough to beat the (not very challenging) CPU-controlled trainers. My attempt takes a much less complete approach. I don’t have the money to pay for either GPUs or GPU time, so I thought I’d work on something smaller. I’ll focus on just winning some battles.</p>

<p>By focusing on a subset of the game (the battles), we can significantly reduce the RL agent’s search space, leading to faster training, lower resource requirements, and faster iteration. We can further reduce the search space by intelligently setting the RL agent’s world state, but I’ll discuss this more later!</p>

<p>If you’re unfamiliar with the Pokémon battle system, it’s a synchronous turn-based system where all users (AI agents, CPU-controlled agents, or human users) select an action at the same time, and those actions then play out over a turn. The actions can include using an attack (that has a limited number of uses), swapping to a different Pokémon, or using an item. Once a turn has played out, the new turn begins (actions are chosen), and this repeats until all of your, or your opponent’s Pokémon have fainted (have 0 health points (HP)). The strategy of Pokémon comes from matching types of Pokémon. Each Pokémon has an elemental type (e.g. Fire, Water, Grass, etc.), with some types being stronger against others. When battling, you want to use a Pokémon that has a type advantage against the opponent to deal maximum damage to them and take less damage yourself. That is the very basic explanation. You can also have different attack types like physical and special, with Pokémon having better defences against physical and special attacks, and then moves that increase the attack/defence ability of your Pokémon, or decrease the attack/defence ability of the opponent, but I’m not skilled enough to write a primer on optimal Pokémon battle techniques (that’s for the agent to find out).</p>

<h2 id="game-emulation">Game emulation</h2>
<p>To run the game and pass input to an RL model, I’m using <a href="https://github.com/Baekalfen/PyBoy">PyBoy</a>, which is a Game Boy/Game Boy Color emulator written in Python. It gives us access to the screen (pixel values in a convenient numpy array), we can send inputs (d-pad, a, b, start, select) and can save and reload states (useful for resetting runs). The emulator can also run at uncapped speeds, allowing us to train rapidly since each battle usually takes a few minutes. All of these features provide us with a suitable environment for automating Pokémon gameplay.</p>

<p>When training an RL agent, we need a metric to assess how well it’s performing and to guide future behaviour with rewards or the promise of a reward. As a first idea, the health of the opponent Pokémon seems like a good place to start. The question is, how do we get this information for the reward?</p>

<p>PyBoy emulates the hardware of the Game Boy Color, recreating the CPU, sound and graphics processor as closely as possible (or as closely as is needed for the program’s aims). This extends to the behaviour and setup of RAM. Since the opponent’s Pokémon’s health is in the game, it should be somewhere in RAM 🙄.</p>

<h2 id="hacking-pokémon-crystal">Hacking Pokémon Crystal</h2>
<p>The ‘RAM map’ of Pokémon Crystal is quite well documented (I started with <a href="https://datacrystal.tcrf.net/wiki/Pok%C3%A9mon_Crystal/RAM_map">datacrystal</a>, then moved on to <a href="https://archives.glitchcity.info/forums/board-76/thread-1342/page-0.html">Glitch City</a>, which seems more complete). From the RAM map, we can find the locations of many pieces of data from the game, e.g. the amount of money the player has (<code class="language-plaintext highlighter-rouge">0xd84e-0xd850</code>), or the badges they have from the Johto region (<code class="language-plaintext highlighter-rouge">0xd857</code>).</p>

<p>Checking the RAM map, we can easily find the memory address for the opponent Pokémon’s health (<code class="language-plaintext highlighter-rouge">0xd216-0xd217</code>).</p>

<p>Now, while we’re here, it’s fun to read program data from RAM. But it’s more fun to write to it! 😏 Tangent time!</p>

<p>Let’s start with something easy. Address <code class="language-plaintext highlighter-rouge">0xdca1</code> tells the game how many repel steps we have left (repels block wild Pokémon encounters). So, if we change that value in RAM, then we shouldn’t encounter a wild Pokémon!
So, I set up the code:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">start_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">prev_repel</span> <span class="o">=</span> <span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xdca1</span><span class="p">]</span>

<span class="k">while</span> <span class="n">pb</span><span class="p">.</span><span class="n">tick</span><span class="p">():</span>
    <span class="k">if</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start_time</span> <span class="o">&gt;</span> <span class="mi">10</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="s">"Reset repel"</span><span class="p">)</span>
        <span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xdca1</span><span class="p">]</span> <span class="o">=</span> <span class="mi">10</span>
        <span class="n">start_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
    
    <span class="c1"># Print the repel value if it changes
</span>    <span class="k">if</span> <span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xdca1</span><span class="p">]</span> <span class="o">!=</span> <span class="n">prev_repel</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xdca1</span><span class="p">])</span>
        <span class="n">prev_repel</span> <span class="o">=</span> <span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xdca1</span><span class="p">]</span>
</code></pre></div></div>
<p>This code updates our repel count to 10 every 10 seconds, and prints out the new repel value every time we take a step. What’s interesting is that the game also reports when we run out of repel:</p>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/pkm_hack_repel.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>This works!</p>

<p>Let’s do a little more experimentation. Let’s try to force a specific Pokémon (Celebi) to appear during a wild Pokémon battle, and set that Pokémon’s health to 1:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Set's Pokémon health to 1 across the two bytes that represent it
</span><span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xd216</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span>
<span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xd217</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</code></pre></div></div>
<p>According to the memory map, <code class="language-plaintext highlighter-rouge">0xd204</code>, <code class="language-plaintext highlighter-rouge">0xd206</code> and <code class="language-plaintext highlighter-rouge">0xd22e</code> correspond to the ID of the opposing Pokémon. When we start a random battle, those memory values change to the Pokémon’s Pokédex number. So what happens if we overwrite these values with a target Pokédex ID?</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xd204</span><span class="p">]</span> <span class="o">=</span> <span class="mi">251</span>
<span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xd206</span><span class="p">]</span> <span class="o">=</span> <span class="mi">251</span>
<span class="n">pb</span><span class="p">.</span><span class="n">memory</span><span class="p">[</span><span class="mh">0xd22e</span><span class="p">]</span> <span class="o">=</span> <span class="mi">251</span>
</code></pre></div></div>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/target_pkm_attempt_1.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>Celebi (Pokédex ID 251) should not be in this patch of grass. In fact, Celebi was a special legendary Pokémon that could only be captured in Ilex Forest (and was only available from a special event in Gold and Silver).</p>

<p>Now we’re comfortable with hacking RAM values, what do we want to achieve? Well, we’d like an agent that can take any team of Pokémon at any level and beat the CPU-controlled opponent in a reasonable battle (e.g., if the agent has level 1 Pokémon and the CPU has level 100, I don’t expect it to win that match-up!).</p>

<p>What do we need to achieve this?</p>
<ul>
  <li>Have a save state that loads the game <em>just</em> before a battle begins (i.e. a few frames before).</li>
  <li>Set the agent’s and CPU’s party to some arbitrary Pokémon with arbitrary moves and arbitrary levels.</li>
  <li>Provide the RL agent with the world state at the start of each turn.</li>
</ul>

<p>Let’s get started!</p>

<h2 id="setting-up-the-ai-environment">Setting up the AI environment</h2>

<h3 id="battle-state">Battle state</h3>
<p>PyBoy allows us to save the state of a game, which captures the contents of RAM, along with the execution environment (program counter, etc.) at any point. I chose to save just before your first battle with your rival. My first attempt was too early: the rival’s Pokémon were set after the first frame was executed, overwriting the hacked team I gave him. After a little trial and error, I managed to save a state just after the rival’s Pokémon were set, which means that we have free rein to set whatever Pokémon we wanted him to have in memory, and it will not be overwritten with any initialisations at the start of the battle.</p>

<h3 id="setting-agent-and-cpu-pokémon">Setting Agent and CPU Pokémon</h3>
<p>This was a little more involved 😅. The teams of Pokémon the agent and CPU can have are nicely arranged in memory, with one Pokémon following on from the previous and all of the properties being consistent in their offset from the first property (Pokémon species (the Pokédex ID)). This makes it easy for us to automate the population of this data.</p>

<p>Apart from the values of <code class="language-plaintext highlighter-rouge">start_addr</code>, <code class="language-plaintext highlighter-rouge">species_addr</code> and <code class="language-plaintext highlighter-rouge">name_addr</code>, which change depending on whether we’re populating the agent or CPU Pokémon and which Pokémon number (1-6), all of the data can be filled in as an offset:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="p">]</span> <span class="o">=</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'dex_id'</span><span class="p">]</span>
<span class="n">memory</span><span class="p">[</span><span class="n">species_addr</span><span class="p">]</span> <span class="o">=</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'dex_id'</span><span class="p">]</span>

<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span> <span class="c1"># Held item
</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">2</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">6</span><span class="p">]</span> <span class="o">=</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'moves'</span><span class="p">]</span>

<span class="c1"># EV values don't matter here
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">11</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">13</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="c1"># HP EV
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">13</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">15</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="c1"># Attk EV
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">15</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">17</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="c1"># Def EV
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">17</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">19</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="c1"># Speed EV
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">19</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">21</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="c1"># Special EV
</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">23</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">27</span><span class="p">]</span> <span class="o">=</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'move_pp'</span><span class="p">]</span>

<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">31</span><span class="p">]</span> <span class="o">=</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'level'</span><span class="p">]</span> <span class="c1"># Pkm level
</span><span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">9</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">11</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>

<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">33</span><span class="p">]</span> <span class="o">=</span> <span class="mi">255</span> <span class="c1"># Seems to be related to evolution? Setting this to 255 stops evolution
</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">34</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">36</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'hp'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">36</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">38</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'hp'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">38</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">40</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'attk'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">40</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">42</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'def'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">42</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">44</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'spd'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">44</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">46</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'spatk'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">memory</span><span class="p">[</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">46</span><span class="p">:</span><span class="n">start_addr</span><span class="o">+</span><span class="mi">48</span><span class="p">]</span> <span class="o">=</span> <span class="n">store_integer</span><span class="p">(</span><span class="n">pkm_data</span><span class="p">[</span><span class="s">'spdef'</span><span class="p">],</span> <span class="mi">2</span><span class="p">)</span>

<span class="k">if</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'nickname'</span><span class="p">]</span> <span class="o">==</span> <span class="bp">None</span><span class="p">:</span>
	<span class="n">pkm_data</span><span class="p">[</span><span class="s">'nickname'</span><span class="p">]</span> <span class="o">=</span> <span class="s">"TEST"</span>

<span class="n">nickname</span> <span class="o">=</span> <span class="p">[</span><span class="nb">int</span><span class="p">(</span><span class="n">chr_to_hex</span><span class="p">[</span><span class="n">s</span><span class="p">],</span> <span class="mi">16</span><span class="p">)</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">pkm_data</span><span class="p">[</span><span class="s">'nickname'</span><span class="p">]]</span>

<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">nickname</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">10</span><span class="p">:</span>
	<span class="n">long_name</span> <span class="o">=</span> <span class="n">nickname</span><span class="p">[:</span><span class="mi">10</span><span class="p">]</span> <span class="o">+</span> <span class="p">[</span><span class="nb">int</span><span class="p">(</span><span class="mh">0x50</span><span class="p">)]</span>
<span class="k">else</span><span class="p">:</span>
	<span class="n">long_name</span> <span class="o">=</span> <span class="n">nickname</span> <span class="o">+</span> <span class="p">[</span><span class="nb">int</span><span class="p">(</span><span class="mh">0x50</span><span class="p">)]</span><span class="o">*</span><span class="p">(</span><span class="mi">11</span><span class="o">-</span><span class="nb">len</span><span class="p">(</span><span class="n">nickname</span><span class="p">))</span>

<span class="n">memory</span><span class="p">[</span><span class="n">name_addr</span><span class="p">[</span><span class="mi">1</span><span class="p">]:</span><span class="n">name_addr</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span><span class="o">+</span><span class="mi">11</span><span class="p">]</span> <span class="o">=</span> <span class="n">long_name</span> <span class="c1"># 11 chars (10 with terminating hex 50)
</span></code></pre></div></div>

<p>In the code above, we’re populating the memory addresses with data from a dictionary (<code class="language-plaintext highlighter-rouge">pkm_data</code>), but where does this data come from? As Pokémon fans know, the Pokémon properties (like HP, attack, and defence) depend on the Pokémon itself (some Pokémon have naturally higher attack or defence) and the level. If we want to create teams of Pokémon of arbitrary levels, we need to know how to calculate the correct properties. This is where Pokémon base stats, EVs and IVs come in… 😮‍💨.</p>

<p>Each Pokémon has a series of base stats for HP, attack, defence, special attack, special defence and speed. These base stats kind of tell you what the Pokémon is good at (e.g., a high defence stat tells you that a Pokémon can take a lot of hits, a high speed stat tells you that the Pokémon is likely to make its move first in the turn order). These values change as the Pokémon levels up, but the way they change depends on both the EVs and the IVs. EVs are given to a Pokémon after they defeat another Pokémon in battle, based on the base stats of the Pokémon it defeated (kind of like absorbing the power of enemies you defeat 😬). As the Pokémon defeats enemies, these EVs accrue and contribute to the increase in stats during level up (I don’t think the EVs reset at level up). The maximum EV for each stat is 65535. Pokémon IVs (Individual Values) are kind of like a Pokémon’s genes. They are values specific to each individual Pokémon that determine how well it scales in a particular stat (the maximum IV is 15 for each stat (0b1111)). For example, Umbreon (the best Pokémon) can have stats:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong>HP</strong></th>
      <th>Attack</th>
      <th>Defence</th>
      <th>Sp.Attack</th>
      <th>Sp.Defence</th>
      <th>Speed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Base</strong></td>
      <td>95</td>
      <td>65</td>
      <td>110</td>
      <td>60</td>
      <td>130</td>
      <td>65</td>
    </tr>
    <tr>
      <td><strong>Lvl. 50</strong></td>
      <td>155-201</td>
      <td>70-116</td>
      <td>115-161</td>
      <td>65-111</td>
      <td>135-181</td>
      <td>70-116</td>
    </tr>
    <tr>
      <td><strong>Lvl. 100</strong></td>
      <td>300-393</td>
      <td>135-228</td>
      <td>225-318</td>
      <td>125-218</td>
      <td>265-358</td>
      <td>135-228</td>
    </tr>
  </tbody>
</table>

<p>(Data from <a href="https://www.serebii.net/pokedex-gs/197.shtml">Serebii</a>).</p>

<p>The variation in stats comes from the EVs and IVs. The equation for calculating a Pokémon’s HP at any level based on base stats, EVs and IVs is:</p>

\[\text{HP} = \left\lfloor \frac{\left( (\text{Base} + \text{IV}) \cdot 2 + \left\lfloor \frac{\left\lceil \sqrt{\text{EV}} \right\rceil}{4} \right\rfloor \right) \cdot \text{level}}{100} \right\rfloor + \text{level} + 10.\]

<p>With all other stats calculated as:</p>

\[\text{Stat} = \left\lfloor \frac{\left( (\text{Base} + \text{IV}) \cdot 2 + \left\lfloor \frac{\left\lceil \sqrt{\text{EV}} \right\rceil}{4} \right\rfloor \right) \cdot \text{level}}{100} \right\rfloor + 5.\]

<p>(Equations from <a href="https://bulbapedia.bulbagarden.net/wiki/Individual_values">Bulbapedia</a>). To keep things simple, when giving the agent or CPU Pokémon, we give them with the maximum stats (EVs of 65535 and IVs of 15). An interesting caveat to note in the calculation is that $\sqrt{\text{EV}}$  is capped at 255.</p>

<p>So, we can see how we can add whatever Pokémon we want to the team, and calculate the correct (maximised) stats at whatever level. But we still have two issues. How do we know the Pokémon’s base stats, and how do we know what moves we can assign to the Pokémon, consistent with its level?</p>

<p>There are many Pokémon databases out there, but the one by <a href="https://github.com/veekun/pokedex">veekun</a> does exactly what we need. It just has a bunch of CSV files that we can comb through to get the data we need. In particular, a <a href="https://github.com/veekun/pokedex/blob/master/pokedex/data/csv/pokemon.csv">list of all Pokémon</a> (so we can find the Pokémon name based on the Pokédex ID), a <a href="https://github.com/veekun/pokedex/blob/master/pokedex/data/csv/pokemon_stats.csv">list of Pokémon stats</a> (so we can get the Pokémon base stats), a <a href="https://github.com/veekun/pokedex/blob/master/pokedex/data/csv/moves.csv">list of all possible Pokémon moves</a>, and finally <a href="https://github.com/veekun/pokedex/blob/master/pokedex/data/csv/pokemon_moves.csv">a list of moves that each Pokémon can learn</a>, and the levels they learn them at. With some cool SQL inner-joins (that I did have LLM help with as it’s been a while since I’ve done databases), we can query any Pokémon from Gen II (the generation of Crystal), get the Pokémon base stats, and find all possible moves it can learn, and the level it can use those moves at. Here’s how we can create the Pokémon in code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pkm_info</span> <span class="o">=</span> <span class="n">process_input_pkm</span><span class="p">([</span>
    <span class="p">(</span><span class="s">"Umbreon"</span><span class="p">,</span> <span class="s">"Hello"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"tackle"</span><span class="p">,</span> <span class="s">"pursuit"</span><span class="p">,</span> <span class="s">"moonlight"</span><span class="p">,</span> <span class="s">"bite"</span><span class="p">]),</span>
    <span class="p">(</span><span class="s">"porygon2"</span><span class="p">,</span> <span class="s">"World"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"conversion-2"</span><span class="p">,</span> <span class="s">"psybeam"</span><span class="p">,</span> <span class="s">"tri-attack"</span><span class="p">,</span> <span class="s">"zap-cannon"</span><span class="p">]),</span>
    <span class="p">(</span><span class="s">"skarmory"</span><span class="p">,</span> <span class="s">"This"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"steel-wing"</span><span class="p">,</span> <span class="s">"peck"</span><span class="p">,</span> <span class="s">"fly"</span><span class="p">,</span> <span class="s">"sky-attack"</span><span class="p">]),</span>
    <span class="p">(</span><span class="s">"Entei"</span><span class="p">,</span> <span class="s">"Seems"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"flamethrower"</span><span class="p">,</span> <span class="s">"fire-blast"</span><span class="p">,</span> <span class="s">"stomp"</span><span class="p">,</span> <span class="s">"roar"</span><span class="p">]),</span>
    <span class="p">(</span><span class="s">"gyarados"</span><span class="p">,</span> <span class="s">"To"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"hydro-pump"</span><span class="p">,</span> <span class="s">"Rain-dance"</span><span class="p">,</span> <span class="s">"Hyper-Beam"</span><span class="p">,</span> <span class="s">"Dragon-Breath"</span><span class="p">]),</span>
    <span class="p">(</span><span class="s">"pikachu"</span><span class="p">,</span> <span class="s">"Work"</span><span class="p">,</span> <span class="mi">100</span><span class="p">,</span> <span class="p">[</span><span class="s">"Thunderbolt"</span><span class="p">,</span> <span class="s">"dynamic-punch"</span><span class="p">,</span> <span class="s">"thunder"</span><span class="p">,</span> <span class="s">"mega-punch"</span><span class="p">])</span>
<span class="p">])</span>
</code></pre></div></div>

<p>This gives us the result:</p>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/pkm_custom_team.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<h3 id="getting-a-world-state">Getting a world state</h3>
<p>Now that we can set the agent and CPU Pokémon arbitrarily, we can re-visit the our todo list:</p>
<ul>
  <li><del>Have a save state that loads the game <em>just</em> before a battle begins (i.e. a few frames before).</del></li>
  <li><del>Set the agent’s and CPU’s party to some arbitrary Pokémon with arbitrary moves and arbitrary levels.</del></li>
  <li>Provide the RL agent with the world state at the start of each turn.</li>
</ul>

<p>Now we <em>just</em> need to provide the RL agent with the world state so it can decide what to do at some regular interval. In Pokémon battles, we have turns, so getting a world state at the start of the turn, then deciding what to do with that turn sounds like an obvious choice. But how do we invoke the agent at the correct time?</p>

<p>This seems like a ridiculous question. There should <em>just</em> be a number somewhere in memory that increments with the turn, right? Well, it doesn’t seem to be as easy as that. First off, there is no documented turn counter reported by <a href="https://datacrystal.tcrf.net/wiki/Pok%C3%A9mon_Crystal/RAM_map">datacrystal</a> or <a href="https://archives.glitchcity.info/forums/board-76/thread-1342/page-0.html">Glitch City</a>, and second, as I’ll discuss later, there is no (to my knowledge) clean turn transition point.</p>

<p>To investigate how we can call the agent at the correct time, I created a live memory map using <a href="https://www.pygame.org/news">PyGame</a>. The live memory map shows how specified RAM values change over time (my very simple implementation changes the colour of a block when the previous frame had a different value in that RAM address). This should allow us to see which RAM values are associated with the turn transition. The <a href="https://gbdev.io/pandocs/Memory_Map.html">Pan Docs memory map</a> tells us that the Game Boy Color working RAM (WRAM) is between <code class="language-plaintext highlighter-rouge">0xc000 - 0xdfff</code>, which also aligns with the RAM values we’ve been changing to manipulate the game! So I’ll start from <code class="language-plaintext highlighter-rouge">0xc000-0xcfff</code> to keep things manageable:</p>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/c_block_mem_check.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>Now <code class="language-plaintext highlighter-rouge">0xd000-0xdfff</code>:</p>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/d_block_mem_check.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>We can see much more activity in the <code class="language-plaintext highlighter-rouge">0xc...</code> block of memory than <code class="language-plaintext highlighter-rouge">0xd...</code>, but both are quite busy around the turn transition.</p>

<p>After a lot of trial-and-error, narrowing down, and times of pure frustration, I think that memory address <code class="language-plaintext highlighter-rouge">0xc6dc</code> is the turn counter. So, we can grab the world state (Pokémon health information, number of times we can use a move etc.) when the data in location <code class="language-plaintext highlighter-rouge">0xc6dc</code> changes? Of course not! From some experimentation, the turn counter increments before all damage calculations have been done, so the health of the Pokémon is incorrect. This means that we need to wait until the official beginning of the next turn i.e. when the user can actually provide inputs. 😤 Yet another hurdle. Almost like the game was made with little regard to how an AI could be trained on it in the future…</p>

<p>Something that I noticed while inspecting the RAM of the running game, was that when the user selects a different option (Fight, Pkmn, Pack, Run), there is a memory address (<code class="language-plaintext highlighter-rouge">0xcfac</code>) that responds to this, storing a different value for each selected value. The fight action is always selected at the start of the turn, clearing whatever was selected at the end of the last turn, meaning that we usually have a transition from some value to 193. So, to detect the start of the turn we can trigger on this transition. The problem is that if we just listen for a transition from any number to 193, we will trigger when we select a different option (e.g. Pkmn) then change our mind and go back to fight. But none of the selections set memory location <code class="language-plaintext highlighter-rouge">0xcfac</code> to 0. So, can we listen for the official turn change measured by <code class="language-plaintext highlighter-rouge">0xc6dc</code>, set the value of <code class="language-plaintext highlighter-rouge">0xcfac</code> to 0, and then listen for the transition from 0 → 193?</p>

<video controls="" width="640">
  <source src="/research/ai/rl/2026/01/14/res/turn_transition.mp4" type="video/mp4" />
  Your browser does not support the video tag.
</video>

<p>Yes. It works… 😵‍💫</p>

<p>Back to the todo list:</p>
<ul>
  <li><del>Have a save state that loads the game <em>just</em> before a battle begins (i.e. a few frames before).</del></li>
  <li><del>Set the agent’s and CPU’s party to some arbitrary Pokémon with arbitrary moves and arbitrary levels.</del></li>
  <li><del>Provide the RL agent with the world state at the start of each turn.</del></li>
</ul>

<p>Amazing!</p>

<h2 id="next-steps">Next steps</h2>
<p>You may have been a bit confused by some of the choices being made in that battle. That may be because an agent made those choices at random! So now we need to train it to make intelligent decisions!</p>

<p>This brings you up to date with where I’m currently at. The next steps are going to be the tricky part (as if everything else was simple to do). Most of the future complexity will be around the formal definition of the problem and the architecture of the RL model.</p>

<p>I started this project thinking that I’d define the world state as the observables of the game (agent Pokémon health, amount of uses each of each move of the agent’s Pokémon, opponent health), while this gives us a very restricted search space (which hopefully translates to fast training), it may also be too restricted to allow for generality if we change Pokémon teams. It also means the agent may not be able to learn Pokémon type matchups, since we never explicitly state what our Pokémon is or what the opponent’s is. Time and experiments will tell, but I’m pleased with the progress I’ve been able to make, and look forward to making more mistakes in the future!</p>

<p>This has been a really awesome project so far. I’ve learned a lot about hacking Game Boy Color games (not sure how useful that is), and I really had to think about the design of the pipeline feeding an AI model (in something other than the image-based networks I’m used to), specifically in terms of how we represent the world with data that can be used to find something useful.</p>

<p>I’m still feeling energised to get this project finished and hope to have more to show soon!</p>]]></content><author><name></name></author><category term="research" /><category term="ai" /><category term="rl" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Wav2Lip for modern GPUs</title><link href="/research/ai/old-code/2025/10/30/wav2lip.html" rel="alternate" type="text/html" title="Wav2Lip for modern GPUs" /><published>2025-10-30T17:00:00+00:00</published><updated>2025-10-30T17:00:00+00:00</updated><id>/research/ai/old-code/2025/10/30/wav2lip</id><content type="html" xml:base="/research/ai/old-code/2025/10/30/wav2lip.html"><![CDATA[<p>We need <a href="https://github.com/Rudrabha/Wav2Lip/">Wav2Lip</a> for some research we’re doing on video authentication. But this project is from 2019-2020 so of course it doesn’t work anymore. This does give me an opportunity to work with <a href="https://www.docker.com/">docker</a> again though, particularly in the context of getting old code to work, which has some particular puzzles associated with it. For example, finding versions cuda that are old enough to work with the ancient versions of PyTorch, while still supporting a modern graphics driver (and trying to take security into account when using old OS images that are no longer patched 😬)!</p>

<p>This docker container seems to work on my computer (Ubuntu 24 with a 3070 GPU). It will only work on a computer with an NVIDIA GPU and may need the <a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html">container toolkit</a>.</p>

<p>The container is a Ubuntu 18.04 environment (just like the Wav2Lip one) and installs an old version of CUDA so we can use the older versions of PyTorch etc. Since the container is old, there are some security concerns which (I hope) are addressed by creating a non-priveleged user in the dockerfile who we use to execute all of the code.</p>

<p>I’ve set it all up so when the dockerfile is built and run (code snippets below), you should be left in a bash environment you can use to execute <code class="language-plaintext highlighter-rouge">inference.py</code>. The directory <code class="language-plaintext highlighter-rouge">ext_files</code> is mounted as an external volume to the container, so you should have access to it (and all files inside it) when in the bash environment. This means that you can put a dataset of images and audio inside <code class="language-plaintext highlighter-rouge">ext_files</code> and then pass the file paths to <code class="language-plaintext highlighter-rouge">inference.py</code> to generate the videos, which are then saved to the <code class="language-plaintext highlighter-rouge">ext_files</code> directory (just remember to change the <code class="language-plaintext highlighter-rouge">--outfile</code> file name to avoid overwriting previously generated videos). When you’re finished and exit the container the generated videos should be in the <code class="language-plaintext highlighter-rouge">ext_files</code> directory.</p>

<p>Here is the build command:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker build <span class="nt">-t</span> wav2lip <span class="nt">-f</span> wav2lip <span class="nb">.</span>
</code></pre></div></div>

<p>And run command:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--rm</span> <span class="nt">-v</span> ./ext_files:/home/appuser/work/vol <span class="nt">--gpus</span> all <span class="nt">--security-opt</span><span class="o">=</span>no-new-privileges <span class="nt">--cap-drop</span> all <span class="nt">-it</span> <span class="nt">--entrypoint</span> /bin/bash wav2lip
</code></pre></div></div>

<p>Here’s the code you execute inside the container:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 inference.py <span class="nt">--checkpoint_path</span> wav2lip_gan.pth <span class="nt">--face</span> vol/mona.jpeg <span class="nt">--audio</span> vol/ada.mp3 <span class="nt">--outfile</span> vol/res.mp4
</code></pre></div></div>

<p>Here’s the dockerfile:</p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> nvidia/cuda:12.1.0-base-ubuntu18.04</span>

<span class="c"># Create a non-priveleged user</span>
<span class="k">ARG</span><span class="s"> UID=1000</span>
<span class="k">ARG</span><span class="s"> GID=1000</span>
<span class="k">RUN </span>groupadd <span class="nt">-g</span> <span class="k">${</span><span class="nv">GID</span><span class="k">}</span> appgroup <span class="o">&amp;&amp;</span> <span class="se">\
</span>    useradd <span class="nt">-m</span> <span class="nt">-u</span> <span class="k">${</span><span class="nv">UID</span><span class="k">}</span> <span class="nt">-g</span> <span class="k">${</span><span class="nv">GID</span><span class="k">}</span> <span class="nt">-s</span> /bin/bash appuser

<span class="c"># Install necessary packages</span>
<span class="k">RUN </span>apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\
</span>    python3-pip python3-dev python3-wheel <span class="se">\
</span>    python3-setuptools python3-apt python3-soundfile <span class="se">\
</span>    build-essential wget <span class="se">\
</span>    git <span class="se">\
</span>    libsm6 libxext6 libxrender1 libglib2.0-0 <span class="se">\
</span>    ffmpeg <span class="se">\
</span>    <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-rf</span> /var/lib/apt/lists/<span class="k">*</span>

<span class="c"># Make the workspace and give the user access</span>
<span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /home/appuser/work <span class="o">&amp;&amp;</span> <span class="se">\ </span>
	chown -R ${UID}:${GID} /home/appuser

<span class="k">WORKDIR</span><span class="s"> /home/appuser/work</span>

<span class="c"># Clone the Wav2Lip repo</span>
<span class="k">RUN </span>git clone https://github.com/Rudrabha/Wav2Lip /home/appuser/work
<span class="c"># Give the non-priveleged user access</span>
<span class="k">RUN </span><span class="nb">chown</span> <span class="nt">-R</span> appuser:appgroup /home/appuser/work

<span class="k">RUN </span>pip3 <span class="nb">install</span> <span class="nt">--upgrade</span> pip setuptools wheel

<span class="c"># Download the wav2lip model from google drive</span>
<span class="k">RUN </span>pip <span class="nb">install </span>gdown
<span class="k">RUN </span>gdown https://drive.google.com/uc?id<span class="o">=</span>1_OvqStxNxLc7bXzlaVG5sz695p-FVfYY

<span class="c"># Download the face detection model</span>
<span class="k">RUN </span>wget <span class="nt">-O</span> face_detection/detection/sfd/s3fd.pth https://www.adrianbulat.com/downloads/python-fan/s3fd-619a316812.pth

<span class="c"># Install packages Wav2Lip requires</span>
<span class="k">RUN </span>pip3 <span class="nb">install </span><span class="nv">numpy</span><span class="o">==</span>1.17.1
<span class="k">RUN </span>pip3 <span class="nb">install </span>opencv-python<span class="o">==</span>4.1.0.25
<span class="k">RUN </span>pip3 <span class="nb">install </span><span class="nv">torch</span><span class="o">==</span>1.10.1+cu111 <span class="nv">torchvision</span><span class="o">==</span>0.11.2+cu111 <span class="nv">torchaudio</span><span class="o">==</span>0.10.1 <span class="nt">-f</span> https://download.pytorch.org/whl/cu111/torch_stable.html
<span class="k">RUN </span>pip3 <span class="nb">install </span><span class="nv">tqdm</span><span class="o">==</span>4.45.0
<span class="k">RUN </span>pip3 <span class="nb">install </span><span class="nv">numba</span><span class="o">==</span>0.43.1
<span class="c"># librosa causes a lot of issues, so don't pin a version and let pip figure out compatibility</span>
<span class="k">RUN </span>pip3 <span class="nb">install </span>librosa

<span class="k">USER</span><span class="s"> appuser</span>
</code></pre></div></div>]]></content><author><name></name></author><category term="research" /><category term="ai" /><category term="old-code" /><summary type="html"><![CDATA[We need Wav2Lip for some research we’re doing on video authentication. But this project is from 2019-2020 so of course it doesn’t work anymore. This does give me an opportunity to work with docker again though, particularly in the context of getting old code to work, which has some particular puzzles associated with it. For example, finding versions cuda that are old enough to work with the ancient versions of PyTorch, while still supporting a modern graphics driver (and trying to take security into account when using old OS images that are no longer patched 😬)!]]></summary></entry><entry><title type="html">Explainable AI</title><link href="/research/ai/interpretability/2025/04/24/xai.html" rel="alternate" type="text/html" title="Explainable AI" /><published>2025-04-24T17:30:00+00:00</published><updated>2025-04-24T17:30:00+00:00</updated><id>/research/ai/interpretability/2025/04/24/xai</id><content type="html" xml:base="/research/ai/interpretability/2025/04/24/xai.html"><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Machine Learning and AI models are often viewed as closed-box systems where we provide input and receive an output, and we don’t have a clear idea of how a model arrives at its answer. This lack of transparency can be unnerving depending on the context in which the model is being used, for instance, in safety- and security-critical systems or where major decisions are being made (deciding whether to accept a loan application, etc.). In addition, sometimes it’s just nice to see that a model understands the problem you have assigned to it and is not using some underlying unrelated aspect of the training data to find a shortcut to an answer (leading to failures when deployed in the real world).</p>

<p>Explainable AI methods focus on attributing the prediction of a DNN to input features. In the case of image classification networks, attribution can be assigned to patches of an image (see occlusion attribution) or even down to the pixel level (see integrated gradients).</p>

<p>I have personally used this approach to discuss the effectiveness of models with collaborators, highlighting how datasets are lacking, or questioning the bounds of the task the AI/ML model is required to perform.</p>
<h2 id="occlusion-attribution">Occlusion Attribution</h2>
<p><a href="https://captum.ai/api/occlusion.html">Occlusion-based attribution</a><sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup> is the simplest approach to explainable AI for vision models. With this method, we pass an input to the model and record the output. We then occlude a part of the image by adding a black rectangle of a chosen size and then pass the new input to the network, measuring the change between the original output value and the new one. This rectangle is passed over the image (similar to a convolution kernel). By comparing the change in the output values at the different positions of the occluding rectangle, we can determine the importance of parts of the image to the result. The attribution can be represented as a heat map, which leads to a nice visual way of exploring this aspect of models.</p>

<h2 id="integrated-gradients">Integrated Gradients</h2>
<p><a href="https://captum.ai/api/integrated_gradients.html">Integrated Gradients</a><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> were developed by Sundararajan, Taly &amp; Yan in 2017 to address some of the shortcomings of other explainable AI methods, such as Occlusion-based attribution and DeepLift<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>. The authors posit that two axioms are important when attributing features to a network output. These are:</p>
<ol>
  <li><strong>Sensitivity</strong></li>
  <li><strong>Implementation Invariance</strong></li>
</ol>

<p>We’ll dive a little more into what these actually mean:</p>

<p><strong>1) Sensitivity</strong> - Says that if we pass an input feature and a baseline (a black image in the case of a vision model or the zero embedding vector for text models) to a model, and they produce different outputs, then a non-zero attribution should be assigned to the input feature.</p>

<p><strong>2) Implementation Invariance</strong> - The attributions assigned to two functionally equivalent models (models where the input-output pairings are identical, but implementations differ) should be the same.</p>

<p>While I find the sensitivity argument intuitive to understand, I struggle a bit more with the implementation invariance. The authors argue that during backpropagation of the output with respect to the input, the gradients of the model internals get cancelled out, leading to invariance. This makes sense, but it makes me wonder how true this is in practice. The authors state that if an attribution method fails to be implementation invariant, then the attributions may be sensitive to unimportant aspects of the models, e.g. if the model has more degrees of freedom than necessary to represent the function. From my understanding, model capacity (somewhat related to <em>degrees of freedom</em>) is an incredibly difficult metric to capture (and in my experience, models tend to have higher capacity than needed), so to state that implementation invariance relies on this makes me somewhat confused (but as always, I’m quite sure I’ve probably missed a part of the argument!). If models have more degrees of freedom, this means that a change to the output can be attributed to more than one input feature; therefore, two functionally equivalent models can choose to optimise different features to perform their task, leading to functional equivalence, but not implementation invariance (according to the paper).</p>

<p>To compute the Integrated Gradients of a model $F$, the authors start with an input $x$, and baseline $x’$ and select a dimension $i$ (a pixel in the case of images, or a specific feature like number of bedrooms or square footage in the case of tabular information). A path integral of the gradients is then taken along a linear interpolation from $x’$ to $x$. This is summarised as:</p>

\[\text{IG}_ i(x) := (x_ i - x'_ i) \cdot \int^1_ {\alpha=0} \frac{\partial F(x' + \alpha(x-x'))}{\partial x_ i} \; \text{d}\alpha\]

<p>The value of $\alpha$ is defined as $\alpha \in [0,1]$, which gradually moves along the path towards the original image $x$.</p>

<p>An added advantage of Integrated Gradients is that unlike Occlusion Attribution, the input images are not changed in a way that can lead to unnatural images (images outside of the distribution the models have been trained to operate on). For Occlusion Attribution, any changes in classification assigned to areas of an image could be due to the out-of-distribution nature of the images instead, causing inflated importance of certain parts of the image.</p>

<h2 id="attribution-in-practice">Attribution in Practice</h2>
<p>If we start with a simple MNIST model (two convolution and a fully connected layer, trained to 99% test accuracy), we can see the Occlusion and Integrated Gradients attribution methods in practice.</p>

<p>These are the results for the Occlusion attribution (red indicates positive attribution to the class):
<img src="res/mnist_occlusion_full.png" alt="MNIST images with their occlusion attribution" /></p>

<p>These are the results for the Integrated Gradients attribution:
<img src="res/mnist_intgrad_full.png" alt="MNIST images with their Integrated Gradients attribution" /></p>

<p>As we can see these two sets of results are very similar, with the Integrated Gradient approach having slightly more detail in the attribution. We could probably reduce the stride and sliding window shape further for Occlusion attribution to match the detail of the Integrated Gradients, but this starts to defeat the object of occluding <em>blocks</em> of the image (we’d essentially just be turning individual pixels off at that point).</p>

<p>From this we can start to infer some interesting behaviours of the network we’re analysing:</p>
<ul>
  <li>First, a zero does not need to be a fully enclosed circle (some gaps are allowed), which makes sense since some people do not draw a fully enclosed circle, with the lack of completion occurring at any degree of the circle! It’s interesting to see that this is being taken into account.</li>
  <li>Second, the number 2 (fitting for the second example) is identified if there is a curved horizontal line, with a slanted line emerging from the (approximate) centre of it.</li>
  <li>Finally, the vertical (ish) line of the number 9 has questionable importance during classification. Provided we have a concave curve at the top of the image and some form of mark below it closer to the bottom of the image, it’s quite happy to consider that a 9!</li>
</ul>

<p>From these findings, we can do some exploitation of the model! See the results below for examples of this.</p>

<p><img src="res/handmade_adversaries.png" alt="Seemingly uninterpretable images" /></p>

<p>These images show that we can take the information we discovered about the attribution of features to classes and create examples which have no semantic meaning, but result in high-confidence classifications. The image of the ‘2’ here is particularly striking in just how devoid it is of meaning, yet the model is 100% certain that it’s a 2. This reminds me of rubbish images, discussed in a <a href="">previous post</a>! From these images, we can find weaknesses in datasets which we may be able to address with more data, correcting the ‘holes’, or we may even discover an edge case in the problem we’re trying to solve. All of which leads to interesting conversations and a more exact definition of the task the AI should perform!</p>

<p>Applying attribution to larger models (<a href="https://pytorch.org/vision/stable/models/inception.html">Inception V3</a><sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">4</a></sup>) pre-trained on the ImageNet dataset, we see similar attribution areas in Bronco the beagle<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>.</p>

<p>Occlusion attribution:
<img src="res/bronco_occlusion.png" alt="Bronco image with occlusion attribution" /></p>

<p>Integrated Gradients:
<img src="res/bronco_intgrad.png" alt="Bronco image with Integrated Gradients attribution" /></p>

<p>Both Occlusion and Integrated Gradient attribution highlight the nose, left eye and part of the shoulder as areas that contribute strongly to the classification!</p>

<h2 id="conclusion">Conclusion</h2>
<p>Hopefully this exploration has been interesting! Explainable AI methods provide a really nice visual way to describe what models find important in the classifications they return. From this we can understand where failure modes may exist in underperforming models and address them, either through training on edge cases or a respecification of the task they are being asked to perform.</p>

<hr />
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Zeiler and Fergus (2013) <a href="https://arxiv.org/abs/1311.2901">Visualizing and Understanding Convolutional Networks</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Sundararajan, Taly &amp; Yan (2017) <a href="https://arxiv.org/abs/1703.01365">Axiomatic Attribution for Deep Networks</a> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>Shrikumar et al. (2019) <a href="https://arxiv.org/abs/1704.02685">Learning Important Features Through Propagating Activation Differences</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:4" role="doc-endnote">
      <p>Szegedy et al. (2015) <a href="https://arxiv.org/abs/1512.00567">Rethinking the Inception Architecture for Computer Vision</a> <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p><a href="https://commons.wikimedia.org/wiki/File:Bronco_the_Beagle.JPG">Bronco the Beagle</a>, PumpkinSky, CC BY-SA 4.0 <a href="https://creativecommons.org/licenses/by-sa/4.0">https://creativecommons.org/licenses/by-sa/4.0</a>, via Wikimedia Commons <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><category term="research" /><category term="ai" /><category term="interpretability" /><summary type="html"><![CDATA[Introduction Machine Learning and AI models are often viewed as closed-box systems where we provide input and receive an output, and we don’t have a clear idea of how a model arrives at its answer. This lack of transparency can be unnerving depending on the context in which the model is being used, for instance, in safety- and security-critical systems or where major decisions are being made (deciding whether to accept a loan application, etc.). In addition, sometimes it’s just nice to see that a model understands the problem you have assigned to it and is not using some underlying unrelated aspect of the training data to find a shortcut to an answer (leading to failures when deployed in the real world).]]></summary></entry><entry><title type="html">Feature Visualisation for Transformers (a first attempt)</title><link href="/research/ai/interpretability/feature-vis/2025/04/06/transformer-vis-start.html" rel="alternate" type="text/html" title="Feature Visualisation for Transformers (a first attempt)" /><published>2025-04-06T15:30:00+00:00</published><updated>2025-04-06T15:30:00+00:00</updated><id>/research/ai/interpretability/feature-vis/2025/04/06/transformer-vis-start</id><content type="html" xml:base="/research/ai/interpretability/feature-vis/2025/04/06/transformer-vis-start.html"><![CDATA[<p>With some of my previous posts (<a href="../../../2024/01/03/unreg_feature_extract.html">here</a>, <a href="../../../2024/01/04/reg-feature-extract.html">here</a>, and <a href="../../../2025/04/02/act-max.html">here</a>) looking at visualising the learned representations of Convolutional Neural Networks (CNNs), the obvious next step is to extend this to transformer-based architectures.</p>

<p>We’ll focus on the Vision Transformer (ViT)<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>, which is a transformer-based image classification architecture that – like the previous architectures I’ve experimented with before – can achieve good classification accuracy on ImageNet and other benchmark datasets. ViT uses a transformer architecture which is as close to the original<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup> as possible, which poses some issues when we move from natural language to images. The original transformer architecture expects inputs to be a finite sequence of one-dimensional tokens, which is challenging if we want to input two-dimensional images. To ameliorate this issue, the authors propose splitting the image into non-overlapping patches, flattening them, passing them through a layer that performs linear projection, taking these patch embeddings and adding a position embedding to add some positional relationship, and finally prepending with the embedding of the class (during training). This converts the image into a sequence of tokens that can then be passed to the transformer architecture (<a href="https://arxiv.org/pdf/2010.11929">see paper for more details</a>). The interesting part of ViT is that it does not contain any convolution layers, so there are no kernels that we can visualise. We need to find another way to extract learned features!</p>

<p>According to 3b1b<sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">3</a></sup>, the interstitial, fully connected layers that occupy the space between the attention layers contain neurons, which represent questions. When we maximise the activation, we find the highest possible response to the question, which should give us some idea about what this question is!</p>

<p>With some minor changes to the visualisation code presented in <a href="../../../2025/04/02/act-max.html">this post</a>, we can get it working with transformer networks! The most significant adjustments are detailed below:</p>
<ul>
  <li>Setting <code class="language-plaintext highlighter-rouge">opt_type</code> to <code class="language-plaintext highlighter-rouge">neuron</code> and changing the extraction process: <code class="language-plaintext highlighter-rouge">act = activation[:, filters]</code>.</li>
  <li>Changing the image transformations: <code class="language-plaintext highlighter-rouge">vit_transform = torchvision.models.ViT_B_16_Weights.IMAGENET1K_V1.transforms()</code>, which are used in the <code class="language-plaintext highlighter-rouge">viz_transform</code> composition rather than the <code class="language-plaintext highlighter-rouge">googlenet_transform</code>.</li>
</ul>

<p>If we target a later layer in the transformer (<code class="language-plaintext highlighter-rouge">encoder.layers.encoder_layer_11.mlp.3</code>) and optimise the image, we get the following result.</p>

<p><img src="res/transformer_vis_1.png" alt="Some random kind of patterns that don't look like anything in particular" /></p>

<p>This image doesn’t really give us many answers with respect to the ‘question’ the neuron may be asking. While we do see some patterns emerge, there isn’t really any connection between the random bursts of something vaguely recognisable. In addition, if we optimise a second time, we get this image.</p>

<p><img src="res/transformer_vis_2.png" alt="Some random kind of patterns that don't look like anything in particular again" /></p>

<p>While there are some aspects of both images that are similar, there seems to be a high variance in the patterns being produced. This indicates that the method is not converging to a consistent high activation image.</p>

<p>One reason for this could be the nature of the activation maximisation process itself and how it interacts with the transformer architecture. The activation maximisation process developed in the previous posts works by passing in some random noise, catching the activation of a particular kernel/neuron within the classification network, traversing the gradient in a direction that causes the kernel/neuron to respond more strongly to it, and then backpropagating that change to the input image. However, in the transformer architecture, the ‘context’ of information within the image (extracted by the attention heads) influences the activation of the interstitial ‘question’ neurons. This means that when we optimise the image, not only are we changing the image, but we’re also changing the context. This kind of makes the optimisation process a moving target because the image and the context influence the neuron we’re trying to optimise for, and changing the image changes the context.</p>

<p>This gives me the impression that this is not a suitable way to explore the internal representations of transformers. I’ll need to find an alternative!</p>

<hr />
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>Dosovitskiy et al. (2020) <a href="https://arxiv.org/pdf/2010.11929">An image is worth 16x16 words: Transformers for image recognition at scale</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Vaswani et al. (2016) <a href="https://proceedings.neurips.cc/paper/2017/file/3f5ee243547dee91fbd053c1c4a845aa-Paper.pdf">Attention is all you need</a> (which has University of Birmingham alum Llion Jones as co-author) <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>3Blue1Brown <a href="https://www.3blue1brown.com/lessons/gpt">But what is a GPT? Visual intro to Transformers - Deep learning, chapter 5</a> <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name></name></author><category term="research" /><category term="ai" /><category term="interpretability" /><category term="feature-vis" /><summary type="html"><![CDATA[With some of my previous posts (here, here, and here) looking at visualising the learned representations of Convolutional Neural Networks (CNNs), the obvious next step is to extend this to transformer-based architectures.]]></summary></entry></feed>