Hierarchical Clustering¶
In hierarchical (or agglomerative) clustering, all data points start as their own cluster. They are connected one by one into tree structures until all data points belong to a single tree.
Pseudocode¶
Here is a generic hierarchical clustering algorithm:
1. create a list of leaf nodes E from all elements you want to cluster
2. find the pair of most similar elements (e1, e2) in E
3. create a new internal node that contains e1 and e2
4. add the new node to E
5. remove e1 and e2 from E
6. repeat from step 2. until there is only one node in E
Exercise: Ancestral Tree of Languages¶
In this exercise, we will examine the ancestry of spoken languages. We assume that all languages originate from one common ancestor in a tree-like manner (i.e. there is no lateral transfer of words).
Step 1: Build a table with words¶
Build a spreadsheet where you write the same words in as many languages as possible. Use one column for each language. The table should be complete. There should be at least 25 words and 6+ different languages.
Load the table from a CSV file into Python using the pandas library:
import pandas as pd
df = pd.read_csv("myfile.csv")
print(df)
Step 2: Similarity of words¶
Implement a function that calculates the similarity of two words. For a first implementation, we will match the occurence of characters, ignoring their exact position:
Complete the code using the terms: float, set, return, union, word2:
def calc_word_similarity(word1: str, word2: str) -> ___:
s1, s2 = ___(word1), set(___)
___ len(s1.intersection(s2)) / len(s1.___(s2))
Test the function with a few examples:
assert calc_word_similarity("hello", "hello") == 1.0
assert calc_word_similarity("home", "dome") == 0.8
assert calc_word_similarity("spam", "eggs") == 0.0
Step 3: Similarity of languages¶
To calculate the similarity of two languages, use the mean of all word similarities. Sort the lines below and add the correct indentation:
for w1, w2 in zip(words1, words2):
from typing import Sequence
sim = calc_word_similarity(w1, w2)
def calc_language_similarity(words1: Sequence[str], words2: Sequence[str]) -> float
from statistics import mean
return mean(word_sims)
word_sims = []
word_sims.append(sim)
Test the function using the actual languages from the table:
print(calc_language_similarity(df["English"], df["Polish"]))
Step 4: Distance matrix¶
Calculate the similarity for all pairs of languages. Put the results into a new table. We will refer to this table as a distance matrix.
Complete the following code:
languages = df.columns
sims = []
for lang1 in languages:
row = []
...
dist_matrix = pd.DataFrame(sims, index=languages, columns=languages)
Use the seaborn library to plot the distance matrix:
import seaborn as sns
sns.heatmap(dist_matrix)
Step 5: Tree data structure¶
For the implementation of the hierarchical clustering, we define data types for the tree nodes. It would be possible to implement everything with Python lists and dictionaries, but the code is likely to become a mess.
from dataclasses import dataclass
@dataclass
class LeafNode:
name: str
words: list[str]
@dataclass
class InternalNode:
name: str
left: LeafNode
right: LeafNode
Step 6: Hierarchical clustering¶
Now we can implement the algorithm from the generic pseudocode:
# 1. create a leaf node for every element of E.
nodes = [LeafNode(lang, df[lang]) for lang in df.columns]
# 2. find the pair of most similar elements (e1, e2) in E
# 3. create a new internal node that contains e1 and e2
# 4. add the new node to E
# 5. remove e1 and e2 from E
# 6. repeat from step 2. until there is only one node in E
Warning
You will notice that the code will not run yet. Why do you think that is?
Step 7: Linkage rule¶
To handle the joined nodes, we will need a helper function that defines how internal nodes are joined. This is called a linkage rule, an important hyperparameter in hierarchical clustering.
Try the following code:
def get_similarity(node1: Union[InternalNode, LeafNode],
node2: Union[InternalNode, LeafNode]) -> float:
if isinstance(node1, InternalNode):
return (
get_similarity(node1.left, node2) +
get_similarity(node1.right, node2)
) / 2
elif isinstance(node2, InternalNode):
return get_similarity(node2, node1)
else:
return calc_language_similarity(...)
Now the algorithm should run.
Try to pretty-print the last remaining node. You should see something that looks remotely similar to a tree:
from pprint import pprint
pprint(nodes)
Step 8: Draw the tree¶
To draw the tree, we will use the pygraphviz library. Install it with:
pip install pygraphviz
To make the drawing easier to implement, we tell the leaves and nodes to draw themselves. Add a draw() method to the LeafNode class:
def draw(self, graph):
graph.node(self.name, self.name)
We do the same for InternalNode:
def draw(self, graph):
self.left.draw(graph)
self.right.draw(graph)
graph.node(self.name, '', shape='point')
graph.edge(self.name, self.left.name, dir='none')
graph.edge(self.name, self.right.name, dir='none')
If everything goes well, we can have a main drawing function take care of the styling:
from graphviz import Digraph
def draw_tree(nodes):
"""uses Graphviz to draw a tree on screen"""
tree = nodes[0]
graph = Digraph(
edge_attr={'dir': 'back', 'color': 'black'},
node_attr={'fontname': 'arial', 'color': 'lightblue', 'style': 'filled'}
)
tree.draw(graph)
graph.render('languages.gv', view=True)
Caveats¶
There exist many variations of the hierarchical clustering idea. You can see the distance metric and linkage rule as hyperparameters.
In hieararchical clustering, you can decide after the clustering, how many clusters you want to have. To get k clusters, you would remove the k connections that were added last.