Roman Klimenko
DA

Deduplication of CluedIn data sets in Python

The cluedin library is a Python SDK for working with the CluedIn data management platform. It provides access to CluedIn's REST and GraphQL APIs.

CluedIn can import data from various sources, transform it, and export it to other systems. Its GraphQL API lets us query that data from Python or Jupyter notebooks just as we would any other data source.

First, install the required libraries:

%pip install cluedin
%pip install levenshtein
%pip install Metaphone
%pip install pandas

Create a JSON file with credentials for the instance. This example assumes it is available at foobar.244.117.198.49.sslip.io:

{
  "domain": "244.117.198.49.sslip.io",
  "org_name": "foobar",
  "user_email": "admin@foobar.com",
  "user_password": "yourStrong(!)Password"
}

Store the file somewhere private, then save its path in an environment variable:

export CLUEDIN_CONTEXT=~/.cluedin/cluedin.json

The following code loads data from CluedIn and saves it to a JSON file for further processing:

import json
import os

import pandas as pd

import cluedin


def flatten_properties(d):
    for k, v in d['properties'].items():
        if k.startswith('property-'):
            d[k[9:]] = v
    del d['properties']
    return d


def get_entities_from_cluedin(entity_type):
    ctx = cluedin.Context.from_json_file(os.environ['CLUEDIN_CONTEXT'])
    ctx.get_token()

    query = """
        query searchEntities($cursor: PagingCursor, $query: String, $pageSize: Int) {
          search(
            query: $query
            sort: FIELDS
            cursor: $cursor
            pageSize: $pageSize
            sortFields: {field: "id", direction: ASCENDING}
          ) {
            totalResults
            cursor
            entries {
              id
              properties(propertyNames:["imdb.name.primaryName"])
            }
          }
        }
    """

    variables = {
        'query': f'entityType:{entity_type}',
        'pageSize': 10_000
    }

    entities = list(
        map(flatten_properties, cluedin.gql.entries(ctx, query, variables)))

    with open('data.json', 'w', encoding='utf-8') as file:
        json.dump(entities, file, ensure_ascii=False,
                  indent=2, sort_keys=False)


if not os.path.exists('data.json'):
    get_entities_from_cluedin('/IMDb/Person')

data = pd.read_json('data.json')
data.set_index('id', inplace=True)
data

The result is a pandas DataFrame with all the entities of type /IMDb/Person:

data 0

The number of iterations in the worst case

Our goal is to find possible duplicate values for the imdb.name.primaryName property.

A naive approach compares every element with every other element in a nested loop. This is the Cartesian square of the set. A dataset with one million elements would produce 1,000,000,000,000 pairs.

A record does not need to be compared with itself, and the order of each pair does not matter. If A is a possible duplicate of B, then B is also a possible duplicate of A. We can therefore cut the work roughly in half, from n^2 comparisons to n * (n - 1) / 2.

Let's check if this calculation is accurate:

dataset_size = 1_000
temp = range(dataset_size)

# the result of n(n-1) is always even, because either n or n-1 is even
expected_iterations = int(dataset_size * (dataset_size - 1) / 2)
actual_iterations = 0

for i, a in enumerate(temp):
    for b in temp[i + 1:]:
        actual_iterations += 1

difference = expected_iterations - actual_iterations

print(
    f'Expected iterations: {expected_iterations}, actual iterations: {actual_iterations}, difference: {difference}')

The result is:

Expected iterations: 499500, actual iterations: 499500, difference: 0

Or we can use the itertools.combinations function:

from itertools import combinations_with_replacement

dataset_size = 1_000

df1 = pd.DataFrame({"name": [f"Person {i}" for i in range(dataset_size)]})

new_df = pd.DataFrame(combinations_with_replacement(
    df1.index, 2), columns=['a', 'b'])

new_df[new_df['a'] != new_df['b']]

The result is 499,500 rows, exactly as expected.

Exact matching

We can avoid this vast number of comparisons when looking for exact matches. The duplicated function in pandas marks entities that have an exact duplicate:

data['exact_duplicate'] = data.duplicated(
    subset=['imdb.name.primaryName'], keep=False)

data

This code runs in less than 0.1 seconds and marks every exact duplicate in our dataset of 1,000,000 entities:

data 1

Double Metaphone

For Double Metaphone, calculate a phonetic code for each name and compare the codes as we did for exact matches:

import metaphone

data['metaphone'] = data['imdb.name.primaryName'].apply(
    lambda x: metaphone.doublemetaphone(x)[0])
data = data[data['metaphone'] != '']

data['metaphone_duplicates_count'] = \
    data[data['metaphone'] != ''] \
        .groupby('metaphone')
        .transform('count')

data.sort_values(['metaphone_duplicates_count', 'metaphone'],
                 ascending=[False, True], inplace=True)

data

This calculation takes a bit less than ten seconds on my three-year-old laptop and gives us the following result:

data 2
data[data['metaphone_duplicates_count'] > 1].nunique()['metaphone']
# 126320

Levenshtein distance

Finding matches by Levenshtein distance requires us to calculate the distance between every pair of names. A naive implementation would take about 285 years on my laptop, so we need to reduce the number of comparisons.

We can reduce the work by excluding exact duplicates, comparing only pairs whose lengths differ by at most one character, and using multiple processes.

We normalize the Levenshtein distance by dividing it by the length of the longer string. This allows a longer name to differ by more characters while staying below the same threshold.

The implementation also follows a map-reduce pattern. It calculates the distance for each pair, stores each nonempty result in a separate file, and then combines those files into one.

# pylint: disable=missing-module-docstring, missing-function-docstring, missing-class-docstring, line-too-long, disable=invalid-name

# deduplication.py

import json
import os
import time
from datetime import timedelta
from hashlib import md5
from multiprocessing import Pool, cpu_count

from Levenshtein import distance

os.makedirs('levenshtein', exist_ok=True)


def find_duplicates(strings):
    a = strings[0]

    md5_hash = md5(a.encode('utf-8')).hexdigest()
    file_path = f'levenshtein/{md5_hash}.json'

    if os.path.exists(file_path):
        return

    length_distance_threshold = 1
    levenshtein_distance_threshold = 0.2

    result = {
        'name': a,
        'duplicates': []
    }

    for b in strings[1:]:
        if abs(len(a) - len(b)) > length_distance_threshold:
            continue

        levenshtein_distance = distance(a, b) / max(len(a), len(b))

        if levenshtein_distance < levenshtein_distance_threshold:
            result['duplicates'].append({
                'name': b,
                'distance': levenshtein_distance
            })

    if len(result['duplicates']) > 0:
        with open(file_path, 'w', encoding='utf-8') as file:
            json.dump(result, file, indent=2)


def main():
    process_start = int(time.time())

    with open('data.json', 'r', encoding='utf-8') as file: # load the dataset
        data = list({x['imdb.name.primaryName'] for x in json.load(file)})

    print(
        f'{timedelta(seconds=time.time() - process_start)} Loaded {len(data)} unique strings from data.json.')

    with Pool(processes=cpu_count()) as pool:
        print(f'{timedelta(seconds=time.time() - process_start)} Starting {cpu_count()} processes to find duplicates...')

        total = len(data)
        thousand_start = time.time()

        for i, _ in enumerate(pool.imap(find_duplicates, [data[i:] for i, _ in enumerate(data)])):
            if i % 1_000 == 0:
                print(
                    f'{timedelta(seconds=time.time() - process_start)} {timedelta(seconds=time.time() - thousand_start)} strings: {i:_} of {total:_} ({i / total * 100:.0f}%)')
                thousand_start = time.time()

        print(f'{timedelta(seconds=time.time() - process_start)} Done!')

    # Reduce

    files = os.listdir('levenshtein')

    duplicates = {}

    for i, file in enumerate(files):
        if i % 1_000 == 0:
            print(
                f'{timedelta(seconds=time.time() - process_start)} {i:_} of {len(files):_} ({i / len(files) * 100:.0f}%)')
        with open(f'levenshtein/{file}', 'r', encoding='utf-8') as f:
            data = json.load(f)

            if data['name'] not in duplicates:
                duplicates[data['name']] = {
                    'count': 0,
                    'duplicates': []
                }

            duplicates[data['name']]['count'] += len(data['duplicates'])
            duplicates[data['name']]['duplicates'].extend(data['duplicates'])

    duplicates = {k: v for k, v in sorted(
        duplicates.items(), key=lambda item: item[1]['count'], reverse=True)}

    for _, v in duplicates.items():
        v['duplicates'] = sorted(
            v['duplicates'], key=lambda item: item['distance'], reverse=False)

    with open('duplicates.json', 'w', encoding='utf-8') as f: # save the result
        json.dump(duplicates, f, indent=2)

    print(len(files))


if __name__ == '__main__':
    main()

Processing one million entities takes several hours, which is still better than 285 years.

A BK-tree could reduce the number of comparisons further, but that deserves its own article.

In practice, I refine the matching conditions on a small sample first, then run the final calculation on the full dataset.