# SAEs you can See: Applying Sparse Autoencoders to Clustering

TL;DR
-----

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/878a0737e7a2e0e5764ebd343c666d996d66166a8598d937.png)

*   We train sparse autoencoders (SAEs) on artificial datasets of 2D points, which are arranged to fall into pre-defined, visually-recognizable clusters. We find that the resulting SAE features are interpretable as a clustering algorithm via the natural rule "a point is in cluster N if feature N activates on it".
*   We primarily work with top-k SAEs (k=1) (as in [Gao et al.](https://arxiv.org/abs/2406.04093)), with a few modifications:
    *   Instead of reconstructing the original $(x,y)$ points, we embed each point into a 100-dimensional space, based off its distance to 100 fixed "anchor" points. The embedding of a point $p$ for an anchor point $a$ is roughly $\exp(-d(p,a)^2)$. This embedded point is both the input and target of the SAE. This embedding allows our method to identify features which are non-linear in $(x,y)$.
    *   We use a variant of ghost gradients to push dead features in the correct direction. This greatly improves the reliability of the training.
*   We achieve great data-efficiency (as low as 50 training data) by training for thousands of epochs.
*   This approach allows one to "see" SAE features, including their coefficients, in a pleasant way:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/ac924a16a5c5ab0666de2ecfc74195b89ec3ec0337e13938.png)

Circles are points in the dataset, with color indicating the feature activation. Triangles indicate decoder weights, with larger, redder triangles indicating larger weights. (There are small blue triangles in every diagram, though they may be hard to see.) A triangle is located where its corresponding "anchor point" is.

**Introduction**
----------------

Using Sparse Autoencoders for dictionary learning is fundamentally an unsupervised learning task: given some data, find the important things in it. If SAEs are good at that, they should be able to solve other unsupervised learning problems. Here, I try to use SAEs on a classic unsupervised learning problem: clustering 2D data. The hope is that SAEs can learn features corresponding to "in cluster 1, in cluster 2, etc". 

We investigate this on artificial data, and find that SAEs semi-reliably find the correct classification, with interpretable activations and decoder weights. 

**Methods**
-----------

### Datasets

We made four synthetic datasets, consisting of separate, visually-identifiable clusters. 

1.  "Basic Blobs" - 5 clusters. Points $(x,y)$ are drawn from normal distributions$X \sim \mathcal N(x_{center}, 1), Y \sim \mathcal N(y_{center}, 1)$ where $(x_{center}, y_{center})$ is the cluster center. The 5 clusters have centers $(0,0), (10,0), (0,10), (10,10), (7,5)$, forming a square pattern with one cluster in the center.
2.  "Blob Grid" - 18 clusters. As with Basic Blobs, but the centers are of the form $(5i, 5j)$ for $0 \leq i,j \leq 5$, $i+5j <18$, forming a grid pattern.
3.  "Random Blobs" - 10 clusters. Points are sampled from a multivariate normal distribution, roughly forming ovals with random centers and eccentricities. The centers of the normal distributions are resampled if they are not sufficiently far apart.
4.  "Lollipops" - 5 clusters. 3 clusters as in "Basic Blobs", but with centers at $(0,0)$, $(5,0)$, and $(0,10)$, plus 2 additional clusters that form thin rectangles as "stems" of the lollipops. The first stem comes down off the $(0,0)$ blob, and the second stem comes off the $(5,0)$ to the right.
    
    ![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/d0cbb96ba9dd62475da50b080e9c25d8e364234bfe7d472e.png)
    

We use classes of varying sizes: each class is randomly assigned a relative frequency from {1,2,3,4}.

### Data Embedding via Anchors

Applying a sparse autoencoder to the point cloud's $(x,y)$ points directly is extremely limited - you can at most read off a linear direction, leading to features like this one:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/96bd81b7fb8b7739501fe23146ed4af698e264c95e3f9f8f.png)

A feature if you train an SAE on the (x,y) coordinates directly, on the "basic blobs" dataset. Its activations are linear in (x,y), so isoclines are straight lines.

Such linear features are insufficient for the purpose of classifying. We will instead embed each $(x,y)$ point in a high-dimensional space, with the goal that clusters are linearly separable and form the natural features of the dataset, which the SAE can find.

To do this, we choose a set $A=\{a_i\}_{i=1}^{n_{anchors}}$ of "anchors", drawn from the same data distribution as the dataset we're training on (on real data, this would correspond to setting aside a fraction of the data as anchors). Points are encoded into $\mathbb R^{n_{anchors}}$, with the $i$th encoding dimension being a function of the distance to the $i$th anchor, given by:

$\displaystyle \mathrm{embed}(p)_i=\exp\left(-\frac{1}{2\mathrm{var(a_1,...,a_n)}} \frac{1}{\sigma^2} \cdot d(a_i, p)^2 \right)$

where $p$ is a point in our dataset, $a_i$ represents the $i$th anchor, $d$ is the usual euclidean distance, $\mathrm{var}(a_1,...,a_n)$ is the variance of the set of anchors, and and $\sigma$ is a hyperparameter controlling the neighborhood of influence of each point relative to the overall dataset. The embeddings from a single anchor look like this:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/e77c924f0ccca9bdeb4f5c551a6431cf959679d60a917172.png)

Now just picture this in 100 dimensions for the 100 anchors, and that's how we embed the point clouds.

Because the embedding function is based on distances and normalized with variance, it is invariant under uniform scaling and isometries (rotations, reflections, etc).

### SAE Architecture

We use an SAE architecture based on the one in [Towards Monosemanticity](https://transformer-circuits.pub/2023/monosemantic-features), but with $$ReLU followed by top-1 as our activation function. The SAE computation is:

$\begin{eqnarray}f&=&\mathrm{act}(W_e(x-b_d)+b_e) \ \tilde x &=&W_df+b_d \end{eqnarray}$

where $x \in \mathbb {R}^{n_{anchors}}$ is an embedded point from the point cloud, $W_e \in \mathbb{R}^{n_{features}\times n_{anchors}}$, $W_d \in \mathbb R^{n_{anchors} \times n_{features}}$, $b_d \in \mathbb R^{n_{anchors}}$, $b_e \in \mathbb R^{n_{features}}$ are the weights and biases of the encoder and decoder, and $\mathrm{act}$ is ReLU followed by top-1 activation. We normalize the columns in $W_e$ and $W_d$ at inference time.

A top-k SAE has two hyperparameters: the number of features, and $k$, the number of features active at one time. We make the number of features to be the number of ground-truth classes, and take $k=1$. 

The way we embed our point cloud also has two hyperparameters, $\sigma$ and $n_{anchors}$. We use $\sigma=\frac{2}{n_{classes}}$ and $n_{anchors}=100$, which were chosen because they anecdotally work well.

### SAE Loss Function

Our main loss function is reconstruction loss:

$\mathcal L_{rec}(x)= ||\tilde x-x||_2^2$

Since top-1 SAEs can easily acquire dead features, we supplement this with a version of ghost grads. Following Anthropic, [we designate a feature as dead if it has not activated in a significant number of previous data](https://transformer-circuits.pub/2024/jan-update#dict-learning-resampling), in our case 1000. To compute the ghost grads, we perform the following procedure:

1.  Compute the error-weighted average residual stream over the batch: $\bar{x}_{err}= \frac{\sum_xL_{rec}(x)x}{\sum_x \mathcal L_{rec}(x)}$, and similarly the error-weighted average error direction: $\bar{d}_{err}= \frac{\sum_xL_{rec}(x)(x-\tilde x)}{\sum_x \mathcal L_{rec}(x)}$.
2.  For each dead feature, add a loss term based on how its encoder direction aligns with $\bar x_{err}$ and how its decoder direction aligns with $\bar d_{err}$. In particular, we compute:$\mathcal L_{ghost}=\displaystyle \sum_{\text{dead feature }f_i}\left(\mathrm{softplus}(-\bar x_{err}W_e^i) +\mathrm{softplus}(-\bar d_{err}W_d^i) \right)$.[^86h22bqyc8b]

The overall loss of the SAE is:

$\mathcal L = \mathcal L_{rec}+\mathcal L_{ghost}$

Our ghost loss is very direct and very crude: it pushes dead features to activate on high-error features (which are presumably an as-yet-unidentified cluster), and for their decoder directions to fix the error. Nonetheless, it is sufficient for our purposes, effectively eliminating dead features, and improving reliability of training runs, especially on harder datasets.

### SAE Training

Our training methods are mostly routine: we use the AdamW optimizer with learning rate 1e-4 and otherwise default parameters.

The one notable exception is the number of epochs: since clustering often suffers from limited data availability, we restrict ourselves to $n_{train}=1000$ points in our training set[^rb391p174], trained for 500 epochs. 

Counting both the training data and anchors, this results in 1100 total samples in our point cloud. Classes can be as small as ~40 points if they have a low relative frequency (see the Datasets section). We run experiments with fewer training points (see below) and find that the model can learn the correct classification on as few as 50 training points (+100 anchors), though with reduced reliability.

Our SAEs are extremely small (the number of parameters is roughly$2*n_{features}*n_{anchors} \approx2000$, depending on the dataset being classified), so training completes quickly, in <10 seconds on my laptop.

### Measuring Results: Cluster Entropy

We measure effectiveness of our model in two ways: reconstruction loss (unlabelled), and cluster entropy (using the generating clusters as labels). Cluster entropy is computed with this method[^0pllz2jjwqmn]: 

1.  Use the true labels to partition each cluster. Measure the entropy of each cluster individually.
2.  Average the entropy across clusters, weighted by the size of the clusters.

For $N$ true classes and $N$ clusters, the entropy lies in the range $[0, \log(N)]$, where lower is better. Based on my visual inspection of clusters, entropy=0.1 is the cutoff between correct and incorrect clusterings.

Experiments and Results
-----------------------

### Baseline Experiments

We ran the training setup described above on all four datasets. On basic\_blobs and random\_blobs, the SAE typically performs very well, resulting in ~perfect classification in the median case. The model is more confused on blob_grid dataset - it often identifies several clusters mostly-correctly, but struggles on several other clusters (though see the later sections for improvements on our technique that make it succeed on this dataset as well). On the lollipops dataset, the SAE has poor entropy because while it correctly finds the division of lollipops into cores and sticks, it splits them in the wrong location.

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/02b1c4f55841771832b23eff0dd2ee366abdcc4372ce7555.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/922d0da793f90aa0062e1358f988d59d56f28f7268855a12.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/116a67a7b10f885d6d8eae0a98a1ff9c033939ae8ece2746.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/daff140d9fee4e7b2e011fea0ad52b2da5a775d5e85aeaa4.png)

### Scale Sensitivity Experiment

Our method relies on the hyperparameter $\sigma$, which changes the region of influence of each anchor, analogous to $\epsilon$ in [DBSCAN](https://en.wikipedia.org/wiki/DBSCAN). I ran a hyperparameter sweep of this on the basic\_blobs and random\_blobs datasets to assess the method's sensitivity.

We find that performance drops if $\sigma$ is too large or too small. For the easier basic_blobs dataset, we get ~perfect performance for $\sigma \in [0.28, 0.96]$. For the harder random\_blobs and blobs\_grid datasets, we get reasonably go good performance for $\sigma>0.2$, but presumably performance tapers off sufficiently large $\sigma$. On $$the lollipops dataset, performance is best for $\sigma \in [0.3, .9]$.

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/1c927db1516858fe423cc139ae048e5aaac4b11b546b035d.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/0553d56dd283d2ccb9a730e64efdfbfb72c3237869a8a4e3.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/b6b7ee151cec7af7082cef46e70ed97c6d3421ddc03cc39a.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/28fc58395a1ddf1678bc60e0685cd932d483805f34776b14.png)

### Data Scarcity Experiment

Since point cloud data is often scarce, we experimented with greatly reducing the size of our training set from the "default" $n_{train}=1000$. In this experiment, we sweep $n_{train}$ through ${10, 20, 30,..., 200}$, compensating for smaller dataset size by increasing epochs to $n_{epochs}=500,000/n_{train}$. In these experiments, we keep a constant $n_{anchors}=100$.

We find that some minimum amount of data is needed for good clustering, but this threshold is surprisingly low. We typically stop seeing performance improvements around by 100 data, though for the blob\_grid and lollipops datasets, this performance is poor. On the easier basic\_blobs dataset, as low as 50 data can produce reliably accurate clusters (the smallest cluster in the training set will consist of ~4 points).

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/9a0de5cbd9f7ce9ee0150b6d4f99aba5fc33fb13324f19f1.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/f3d889ddaf8b648e7f5799a96323cf5ee5bbc4cb90396c91.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/9becde428f13bc08076c3ca6c5e3861207b0cb6e1455ffa3.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/68d275d10bafa56720bc1f9f03aaa08188c8601ce20ca0d4.png)

### Identifying Number of Features Experiment

So far, we've helped our SAE by setting its hyperparameter $n_{features}=n_{classes}$. But often in clustering one does not know $n_{classes}$. Can we use the SAE to determine the correct number of features?

One approach is this: assume that the SAE will have high reconstruction loss if its features straddle multiple classes. Therefore, loss will be high when $n_{features}<n_{classes}$, but will be roughly similar for $n_{features}\geq n_{classes}$. We can sweep $n_{features}$, and identify the point at which adding another feature does not significantly decrease reconstruction loss, which should occur when $n_{features}=n_{classes}$.

This technique works reasonably well on basic_blobs, with with losses leveling off starting at the correct value, $n_{features}=5$. But on the other three datsets, there is not a notable change at the correct number of features.

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/dcdcb6cac7b8c286cc87fd3c35b4e751ec2cc01b3fe01a4e.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/48726e588317e5439522f16272ba59c729533a83f635a939.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/99c278ddfd94b672605958904e12a1d142c32d5514ba185f.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/5c60643e5863edcad805ddecd611f9ba60f0085a6f0a7aaa.png)

### Visualizing Features, Encoders, and Decoders

One benefit of this approach is that the SAE operates on a very visible dataset, and this lets us create diagrams to directly see parts of the SAE, namely where the features activate, the encoder weights, and the decoder weights.

Let's look at another training run on the random_blobs dataset, which produces these classifications:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/15e20271e36038e806aac73a48121c52ee64f9430b1fb3ca.png)

Here we can see one thing already: there is some confusion for the model, where parts of the yellow cloud are incorrectly assigned to the purple, brown, or grey cluster. These "confused points" are typically present near the fringes of a distribution, and we'll show a solution to them in the next experiment. 

What do the feature activations themselves look like? In these graphs, circular points are points in the test dataset, and their color shows whether the feature activates on them. We also draw the "anchor" points as triangles, and show the corresponding weight of the encoder/decoder in its color (redder for more positive, bluer for more negative) and size (by magnitude). 

We graph each feature twice, with the encoder weights shown on the left, and the decoder weights shown on the right:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/ae9ccd8315758fd960997bc3078e9b1bb114dd85d8b0ec4a.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/0e59f4a16a3fd4a3e3631c3b23833a0f454fdbb6cf526467.png)

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/3aa6149c2576e8701c0f2a190b186d0e011061b5495451bc.png)

(I've omitted Features 3-9.)

One thing we can see is that the activations are larger in the center of the cluster, as we'd hope.

Another thing to notice is that the decoder weights are sparse and interpretable, as they activate the most within a cluster. But the encoder weights are all over the place - they are positive all across the dataset. We'll use this insight in the next section to fix the confused points.

\[Edit 11/25: The non-interpretable encoder weights are actually an easy fix: use a small weight decay while turning off encoder normalization. This results in interpretable encoder AND decoder features, like so:\]  
![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/c673ac351fb20ae9b2312f57aaa6f8e530bcfda79111dec6.png)

### Improving the Random Blobs result with "Adjoint Classification"

If you run the Random Blobs dataset with the scale parameter $\sigma=0.2$ (up from 0.11) which seemed best in the scale sensitivity experiment, and with $n_{anchors}=1000$ (up from 100), you get much better clustering results:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/5f11b546da90acaf9c8bb98273fe48cf315fff3073b773b5.png)

The median entropy here is 0.143, down from 0.326 in the "default parameters". 

This is a big improvement, but we have huge number of "confused points", such as the single blue point in the upper-left which is closer to the pink clusters, but oddly gets assigned to the blue cluster.

Seeing in the last experiment that decoder weights are more interpretable, I was inspired to try a process I call *Adjoint Clustering:* we assign clusters using the decoder weights via:

$\mathrm{cluster}(p)=\mathrm{argmax} (W_{d}^T\cdot \mathrm{embed}(p))$

where $p$ is a point and $W_d$ are the decoder weights.$$

By combining improved scale factor, increased number of anchors, and adjoint clustering, we get high-quality results even on the difficult Blob Grid dataset:

![](https://39669.cdn.cke-cs.com/rQvD3VnunXZu34m86e5f/images/4328fe837af85db2e8a5b8ca71d1a3de0c55c54b8341d778.png)

Takeaways for Sparse Autoencoder Research
-----------------------------------------

Here are the components of this research that I hope generalize to other uses of SAEs:

1.  **You can use SAEs for other tasks besides interpreting language models!** This is obviously not news, since SAEs were invented before language models, but its worth remembering.
2.  **You can train your SAE on the same data for many epochs.** In my original training setup, I used 1e5 data, but by drastically increasing the number of epochs, I was able to get a Pareto improvement in both data requirements and performance. This may be because my underlying distribution is relatively simple, but I think its worth trying on language data too, or in any place where data is expensive to produce. A good experiment would be decreasing data by 1 OOM while increasing training epochs by 1 OOM.
3.  **We can validate SAEs on language models by checking for features in other fields.** Something that keeps me up at night is that [the interpretability of SAEs is just an illusion](https://www.lesswrong.com/posts/zzmhsKx5dBpChKhry/comments-on-anthropic-s-scaling-monosemanticity#:~:text=like%20you)%20try.-,A%20Feature%20Isn%27t%20Its%20Highest%20Activating%20Examples,-Let%27s%20look%20at), [as I've written about before](https://www.lesswrong.com/posts/BduCMgmjJnCtc7jKc/research-report-sparse-autoencoders-find-only-9-180-board). I think experiments like this serve as a "training ground" where we can find what SAE architectures and flourishes are needed to find known features.
4.  **An alternative Ghost Grad.** My version of the ghost gradient might be worth trying elsewhere. Its main benefit is that it does not require a second forward pass (it is computed just from $x$ and $\tilde x$), and that it *aggressively* resurrects features \[make a graph showing this\]. That said, it may be too simple or too specialized to work in other cases.
5.  **Adjoint Interpretation.** I found that my encoder weights were far less interpretable than my decoder weights, and I got better performance at the target clustering task by interpreting $W_d^T x$ rather than $W_ex$.

Limitations and Future Work
---------------------------

1.  All my datasets are artificial. I have some real data to try this on next.
2.  I haven't done enough baselining: do SAEs outperform DBSCAN? Is the point cloud embedding"doing all the work"?
3.  I have chosen the two main hyperparameters, $\sigma$ and $n_{features}$, manually. While we've seen that $\sigma$ has a range of reasonable values, my method for finding the correct $n_{features}$ is not reliable.
4.  While decoder directions are interpretable, encoder directions are not. Why? Is there a way to fix this? I've tried tied weights (didn't work) and weight decay (scales everything down, including the anchors which should be active). \[Edit 11/25: This was actually an easy fix: a small weight decay is sufficient, but was previously ineffective because I was normalizing the encoder. Turning off encoder normalization results in interpretable encoder weights.\]
5.  I've tried this approach with Anthropic-style SAEs, but with less success. It is not clear this technique can work without the in-built $k=1$ sparsity.

Code
----

My code is available at [https://github.com/RobertHuben/point\_cloud\_sae/tree/main](https://github.com/RobertHuben/point_cloud_sae/tree/main) (currently poorly documented).

Acknowledgements
----------------

Thanks to Andrew and Logan for their comments on an early draft.

[^86h22bqyc8b]: I've tried ReLU, exp, and the identity function as alternatives to softplus. Softplus performs the best in my initial tests. 

[^rb391p174]: The diagrams in this report show results on the test set, which also consists of 1000 points. 

[^0pllz2jjwqmn]: For a full description, see "Data Clustering: Algorithms and Applications" by Charu C. Aggarwal and Chandan K. Reddy, page 574.