algo-ds/algods/algods.py

260 lines
9.8 KiB
Python
Raw Normal View History

2021-10-08 13:13:52 +00:00
import argparse
2021-10-15 12:36:47 +00:00
import unicodedata
2021-10-08 13:13:52 +00:00
import sys
2021-10-17 08:50:50 +00:00
import numpy as np
SHINGLE_SIZE = 5 # Known as k
2021-10-08 13:13:52 +00:00
def parse_args(argv: dict = None) -> argparse.Namespace:
2021-10-29 14:03:20 +00:00
"""
Parse arguments from the command line using argparse.
This returns an Argparse namespace with the parsed arguments.
Raises an error if an argument is invalid.
--help option is implicitly added.
"""
2021-10-08 13:13:52 +00:00
if argv is None:
argv = sys.argv
2021-10-29 14:03:20 +00:00
parser = argparse.ArgumentParser(description='Document similarity')
# Input document option. Can be whatever file descriptor, including standard input.
parser.add_argument('input', nargs='?', type=argparse.FileType('r'),
help='Documents to read.', default=sys.stdin)
# Give similarity threshold.
2021-10-08 13:13:52 +00:00
parser.add_argument('similarity', nargs='?', type=float, help='Similarity threshold.', default=0.05)
2021-10-29 14:03:20 +00:00
# Optional. Let to display a progress bar while generating and applying permutations,
# which is the most expensive state.
parser.add_argument('--progress', '-p', '--tqdm', action='store_true',
help='Display progress bar while calculating signature matrix.')
2021-10-08 13:13:52 +00:00
return parser.parse_args(argv[1:])
2021-10-15 12:36:47 +00:00
def normalize(doc: str) -> str:
"""
Remove accents from letters, remove non-ascii letters, keep only letters and digits.
2021-10-29 14:03:20 +00:00
For instance, "I l0ve Pokémons & co." gives "il0vepokemonsco".
2021-10-15 12:36:47 +00:00
"""
return ''.join(char for char in unicodedata.normalize(
'NFKD', doc.casefold().replace('æ', 'ae').replace('œ', 'oe'))
if unicodedata.category(char) in ['Lu', 'Ll', 'Nd']
).casefold().encode('ascii', 'ignore').decode('ascii')
2021-10-17 08:50:50 +00:00
def compute_shingles(docs: list[str], single_size: int) -> np.ndarray:
2021-10-29 14:03:20 +00:00
"""
Transform a list of documents into a shingle matrix.
This takes as input a list of documents (strings) that are well-formated (without any special character),
and the length of the shingles.
It outputs a Numpy boolean matrix where M(i, j) = True states that the shingle i appears in the document j.
Since we don't know first the shingles count, we extend regularly the size of the matrix, without
generating an overflow.
"""
# Initialize the shingle matrix with 2 shingles
2021-10-17 09:48:56 +00:00
shingle_matrix = np.zeros((2, len(docs)), dtype=bool)
2021-10-15 12:36:47 +00:00
shingle_id = {}
2021-10-17 08:50:50 +00:00
for doc_id, doc in enumerate(docs):
2021-10-29 14:03:20 +00:00
# Compute different shingles for a single document
2021-10-17 08:50:50 +00:00
char_shing = [doc[i:i + single_size] for i in range(len(doc) - single_size + 1)]
2021-10-15 12:36:47 +00:00
for sh in char_shing:
2021-10-29 14:03:20 +00:00
# The given shingle is unknown. Register it
2021-10-15 12:36:47 +00:00
if sh not in shingle_id:
2021-10-17 08:50:50 +00:00
shingle_id[sh] = len(shingle_id)
if shingle_id[sh] >= len(shingle_matrix):
2021-10-29 14:03:20 +00:00
# Matrix is too slow, so we double its size
2021-10-17 09:48:56 +00:00
shingle_matrix = np.append(shingle_matrix, np.zeros(shingle_matrix.shape, dtype=bool), axis=0)
2021-10-17 08:50:50 +00:00
2021-10-29 14:03:20 +00:00
# Store the information that the shingle is in the document
2021-10-17 09:48:56 +00:00
shingle_matrix[shingle_id[sh], doc_id] = True
2021-10-17 08:50:50 +00:00
2021-10-29 14:03:20 +00:00
# Reduce matrix size to useful content
2021-10-17 08:50:50 +00:00
shingle_matrix = shingle_matrix[:len(shingle_id)]
return shingle_matrix
2021-10-29 14:03:20 +00:00
def compute_optimal_matrix_size(threshold: float) -> tuple[int, int]:
"""
Compute bands and rows number for the signature matrix
such that these values let an approximation of the similarity of two
documents, using LSH.
Recall that the threshold for a signature matrix with b bands of r rows
is given by t = (1 / b) ** (1 / r).
We want that this value is lower than the expected threshold to avoid
true negatives, but we want that this value stay lear the expected
value since we also want to avoid false positives.
Then, we ensure that the estimated threshold is between
2/3*threshold and threshold.
To achieve that, we start from some values, then we add bands if the
threshold is too high, or add some rows per band if it is too high.
"""
# Compute b and r such that s/2 < t < s
# Use at least 2 rows and 16 bands to have good values
rows = 2
bands = 16
est_threshold = (1 / bands) ** (1 / rows)
# Threshold is not acceptable
while not (2 * threshold / 3 < est_threshold < threshold):
# Add bands
if est_threshold >= threshold:
bands *= 2
# Add rows
else:
rows *= 2
est_threshold = (1 / bands) ** (1 / rows)
# Estimated threshold is now near required threshold
return bands, rows
2021-10-17 08:50:50 +00:00
def compute_signature_matrix(shingles: np.ndarray, permutations_count: int, display_tqdm: bool = False) -> np.ndarray:
2021-10-29 14:03:20 +00:00
"""
Implementation of the min-hash algorithm.
We compute a signature matrix of shingles generated by random permutations.
The shingles parameters stands for the shingle boolean matrix (shingle x document)
where shingles[i, j] = True states that shingle i appears in document j.
The permutations_count argument indicates the number of random permutations to generate.
The output is the signature matrix, that has for dimensions (permutations_count x docs_count).
For each permutation, we generate it randomly, then we take the first shingle of the document.
While the permutation generation can be done quickly, the check of the first shingle of a
document in a permutation (which can be achieved with an argmax in a boolean row) may be
quite expensive, and take some time. If supported and option enabled, a progress bar can
be displayed.
"""
2021-10-17 09:27:45 +00:00
shingles_count, docs_count = shingles.shape
2021-10-17 08:50:50 +00:00
2021-10-29 14:03:20 +00:00
# Initialize matrix
2021-10-17 09:27:45 +00:00
signature_matrix = np.inf * np.ones((permutations_count, docs_count))
permutations_iterator = range(permutations_count)
2021-10-29 14:03:20 +00:00
# If supported, load tqdm to display the progress bar
if display_tqdm:
try:
from tqdm import tqdm
2021-10-29 14:03:20 +00:00
permutations_iterator = tqdm(permutations_iterator, unit="perm.")
except ImportError:
print("tqdm is not installed. Please install tqdm before using --tqdm option.")
for permutation_id in permutations_iterator:
2021-10-29 14:03:20 +00:00
# Generate random permutation of shingles
# This is not the most expensive task
2021-10-27 17:45:58 +00:00
permutation = np.random.permutation(shingles)
2021-10-29 14:03:20 +00:00
# For each document, get the smallest shingle after permutation
# This is the expensive operation
2021-10-27 17:45:58 +00:00
signature_matrix[permutation_id] = permutation.argmax(0)
2021-10-17 09:27:45 +00:00
return signature_matrix
2021-10-15 12:36:47 +00:00
2021-10-29 14:03:20 +00:00
def find_candidate_pairs(signature: np.ndarray, bands: int, rows: int) -> set[tuple[int, int]]:
"""
Implementation of the LSH algorithm.
2021-10-15 12:36:47 +00:00
2021-10-29 14:03:20 +00:00
Given a signature matrix and band and rows per band numbers, we want to
find some candidate document pairs to be similar.
2021-10-15 12:36:47 +00:00
2021-10-29 14:03:20 +00:00
We already know that the probability that two documents have the same signature
is the same as their similarity.
2021-10-15 12:36:47 +00:00
2021-10-29 14:03:20 +00:00
Two documents are called are a candidate pair if they have the same signature on
all rows of at least one band.
2021-10-08 13:13:52 +00:00
2021-10-29 14:03:20 +00:00
The output is a set of pairs of document ids.
"""
_, docs_count = signature.shape
2021-10-08 13:13:52 +00:00
2021-10-27 17:45:58 +00:00
candidate_pairs = set()
for band_id in range(bands):
2021-10-29 14:03:20 +00:00
# Get interesting band
band = signature[band_id * rows:(band_id + 1) * rows]
2021-10-27 17:45:58 +00:00
buckets = {}
2021-10-29 14:03:20 +00:00
# Put documents into buckets
# A bucket is the tuple of all signatures of a row
for doc in range(docs_count):
2021-10-27 17:45:58 +00:00
sign_doc = tuple(band[:, doc])
buckets.setdefault(sign_doc, set())
buckets[sign_doc].add(doc)
2021-10-29 14:03:20 +00:00
# Find documents in the same bucket
2021-10-27 17:45:58 +00:00
for bucket in buckets.values():
for doc_a in bucket:
for doc_b in bucket:
if doc_a != doc_b:
2021-10-29 14:03:20 +00:00
# Sort documents for nice output
2021-10-27 17:45:58 +00:00
doc_a, doc_b = min(doc_a, doc_b), max(doc_a, doc_b)
candidate_pairs.add((doc_a, doc_b))
2021-10-29 14:03:20 +00:00
return candidate_pairs
def jaccard_similarity(doc1: set, doc2: set) -> float:
"""
Compute jaccard similarity of two sets.
This is defined as 0 if both sets are empty, |A B| / |A B| if general cases.
"""
if not doc1 or not doc2:
return 0.0
inter = doc1.intersection(doc2)
union = doc1.union(doc2)
return len(inter) / len(union)
def parse(stream, similarity: float, display_tqdm: bool = False) -> None:
"""
Given a stream of documents (separated by line feeds) and a similarity threshold,
we display in standard output an estimation of document pairs that
have a Jaccard similarity higher than the requested threshold.
We use k-shringling, MinHash and LSH to compute the estimation.
"""
docs = [line.rstrip('\n') for line in stream] # Read stream
docs = [normalize(doc) for doc in docs] # Remove special characters and normalize accents
# Compute k-shingles
shingles = compute_shingles(docs, SHINGLE_SIZE)
# Compute best values for permutations count
bands, rows = compute_optimal_matrix_size(similarity)
# Compute signature matrix using MinHash
signature = compute_signature_matrix(shingles, bands * rows, display_tqdm)
# Guess candidate pairs using LSH
candidate_pairs = find_candidate_pairs(signature, bands, rows)
# Sort pairs for a nice output
2021-10-27 17:45:58 +00:00
candidate_pairs = sorted(candidate_pairs)
2021-10-29 14:03:20 +00:00
# For each document pair, compute true Jaccard similarity and display it
2021-10-27 17:45:58 +00:00
for doc_a, doc_b in candidate_pairs:
# Compute true jaccard similarity
shingles_a = set(x for x in range(len(shingles)) if shingles[x, doc_a])
shingles_b = set(x for x in range(len(shingles)) if shingles[x, doc_b])
d = jaccard_similarity(shingles_a, shingles_b)
if d >= similarity:
print(f"{doc_a} {doc_b} {d:.06f}")
2021-10-17 09:48:56 +00:00
2021-10-08 13:13:52 +00:00
2021-10-08 12:53:40 +00:00
def main():
2021-10-29 14:03:20 +00:00
# Parse arguments from command line
2021-10-08 13:13:52 +00:00
ns = parse_args()
if not (0 < ns.similarity <= 1):
raise ValueError(f"Invalid similiarity value: {ns.similarity}")
2021-10-29 14:03:20 +00:00
# Analyse documents
parse(ns.input, ns.similarity, ns.progress)