Algorithm Search⌘K

SimHash in Python

Maps a weighted feature set to a bit fingerprint whose Hamming distance estimates angular similarity, used for near-duplicate detection.

File
simhash.py, 73 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
simhash.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/simhash/. The authors are listed in prov/A430AD.jsonl.

import hashlib
import math
import random
from typing import Callable, Hashable, Mapping, Optional

BitHash = Callable[[Hashable], int]


def default_hash(b: int, seed: int = 0) -> BitHash:
    """A b-bit hash of repr(f), from keyed BLAKE2b."""
    key = f"simhash:{seed}".encode()
    nbytes = (b + 7) // 8

    def h(f: Hashable) -> int:
        out = b""
        block = 0
        while len(out) < nbytes:
            out += hashlib.blake2b(f"{block}:{f!r}".encode(), digest_size=64, key=key).digest()
            block += 1
        return int.from_bytes(out[:nbytes], "big") >> (8 * nbytes - b)

    return h


def simhash(weights: Mapping[Hashable, float], b: int = 64, h: Optional[BitHash] = None) -> int:
    """The b-bit SimHash of a weighted feature set, as an int; bit 1 is the most significant.

    Cost O(m b) for m features.
    """
    if h is None:
        h = default_hash(b)
    v = [0.0] * b  # @line L1
    for f, w in weights.items():  # @line L2
        s = h(f)  # @line L3
        for i in range(b):  # @line L4
            if (s >> (b - 1 - i)) & 1:  # @line L5
                v[i] += w
            else:
                v[i] -= w
    fp = 0
    for i in range(b):  # @line L6
        fp = (fp << 1) | (1 if v[i] > 0 else 0)
    return fp  # @line L7


def simhash_gaussian(weights: Mapping[Hashable, float], b: int = 64, seed: int = 0) -> int:
    """The Gaussian variant: feature f votes w(f) * g_i(f) on bit i, with g_i(f) ~ N(0, 1) from a hash."""
    v = [0.0] * b
    for f, w in weights.items():
        digest = hashlib.blake2b(f"{seed}:{f!r}".encode(), digest_size=16).digest()
        rng = random.Random(int.from_bytes(digest, "big"))
        for i in range(b):
            v[i] += w * rng.gauss(0.0, 1.0)
    fp = 0
    for i in range(b):
        fp = (fp << 1) | (1 if v[i] > 0 else 0)
    return fp


def distance(fp1: int, fp2: int) -> int:
    """The Hamming distance of two fingerprints."""
    return bin(fp1 ^ fp2).count("1")  # @line L8


def angle(x: Mapping[Hashable, float], y: Mapping[Hashable, float]) -> float:
    """The angle between two sparse weight vectors, in radians."""
    dot = sum(w * y.get(f, 0.0) for f, w in x.items())
    nx = math.sqrt(sum(w * w for w in x.values()))
    ny = math.sqrt(sum(w * w for w in y.values()))
    return math.acos(max(-1.0, min(1.0, dot / (nx * ny))))
test_simhash.py 67 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/simhash/. The authors are listed in prov/A430AD.jsonl.

import math
import random

from simhash import angle, default_hash, distance, simhash, simhash_gaussian

TABLE = {"the": 0b1010, "cat": 0b0110, "sat": 0b1100, "mat": 0b0011, "dog": 0b1001, "ran": 0b0101}


def test_example() -> None:
    h = TABLE.__getitem__
    a = simhash({"the": 1, "cat": 3, "sat": 1}, 4, h)
    b = simhash({"the": 1, "cat": 3, "mat": 1}, 4, h)
    c = simhash({"dog": 2, "ran": 1}, 4, h)
    assert (a, b, c) == (0b0110, 0b0110, 0b1001)
    assert distance(a, b) == 0 and distance(a, c) == 4
    assert abs(math.degrees(angle({"the": 1, "cat": 3, "sat": 1}, {"the": 1, "cat": 3, "mat": 1})) - 24.62) < 0.01


def test_edges() -> None:
    assert simhash({}, 64) == 0
    assert distance(0, (1 << 64) - 1) == 64
    x = {"a": 1.0, "b": 2.0, "c": 0.5}
    assert simhash(x) == simhash({f: 7 * w for f, w in x.items()})
    assert simhash(x) == simhash(dict(reversed(list(x.items()))))


def oracle(weights, b, h) -> int:
    fp = 0
    for i in range(b):
        pos = sum(w for f, w in weights.items() if (h(f) >> (b - 1 - i)) & 1)
        neg = sum(w for f, w in weights.items() if not (h(f) >> (b - 1 - i)) & 1)
        fp = (fp << 1) | (1 if pos > neg else 0)
    return fp


def test_random_against_oracle() -> None:
    rng = random.Random(11)
    for t in range(600):
        b = rng.choice([1, 4, 8, 16, 64])
        h = default_hash(b, seed=t)
        doc = {f"w{rng.randint(0, 40)}": rng.randint(1, 5) for _ in range(rng.randint(0, 15))}
        assert simhash(doc, b, h) == oracle(doc, b, h)


def test_pm_one_counterexample() -> None:
    x, y = {1: 1.0}, {1: 1.0, 2: 1.0, 3: 1.0}
    rng = random.Random(5)
    disagree = 0
    trials = 4000
    for _ in range(trials):
        bits = {f: rng.getrandbits(1) for f in (1, 2, 3)}
        h = bits.__getitem__
        disagree += distance(simhash(x, 1, h), simhash(y, 1, h))
    assert abs(disagree / trials - 0.25) < 0.03
    assert abs(angle(x, y) / math.pi - 0.3041) < 1e-3


def test_gaussian_variant_matches_angle() -> None:
    x, y = {1: 1.0}, {1: 1.0, 2: 1.0, 3: 1.0}
    total = 0
    seeds = 300
    for s in range(seeds):
        total += distance(simhash_gaussian(x, 64, s), simhash_gaussian(y, 64, s))
    assert abs(total / (64 * seeds) - angle(x, y) / math.pi) < 0.02

What the next level needs

  • the tests did not run in this build

Read the full page about SimHash