All posts

Compare Databases Like a Ninja

Matching a clean database against a messy, hand-entered one, without losing your mind.

We have a database we know inside out. It’s consistent and readable. We have another database, but we can’t exactly call it consistent; its main column is entered manually. The task: analyze both databases together. Sound familiar?

It certainly does to me; I’ve been there. As part of my military service, I compared a standardized database with one written by hand. How did I do it, you ask? With dozens of hours of manual work. The main drawbacks of working this way:

After reading this article, you’ll know how we can automate this process, step by step:

  1. First Steps 👣
  2. Getting to Know the Databases 🔎
  3. Matching Columns 🔗
  4. Analyzing the Findings 🤔
  5. This Is Just the Beginning 😎

#First Steps 👣

#Libraries

Before I begin, I used Jupyter as my IDE, running locally.

python
import pandas as pd
pd.set_option('display.float_format', lambda x: '%.3f' % x)

import numpy as np

from fuzzywuzzy import process

import matplotlib.pyplot as plt
import seaborn as sns

#Where the Databases Came From

This article is based on the government data repository Data Gov. As someone who loves data, I think this site really is every Data Analyst’s dream. You can find data on a variety of topics: Israel’s economy, transportation, health, and more.

I decided to investigate whether there is a relationship between the number of bank branches and the number of residents in Israeli cities.

#Getting to Know the Databases 🔎

I downloaded the databases to my computer manually, then loaded the CSV files using Python. Data Gov actually supports API, but in this case I decided to keep things simple.

#Banks

I had no trouble accessing the bank database. In the following code snippet, I imported the local CSV file and printed the column names.

python
snifim = pd.read_csv('snifim_he.csv')

snifim.columns
👇🏼
Index(['Bank_Code', 'Bank_Name', 'Branch_Code', 'Branch_Name',
       'Branch_Address', 'City', 'Zip_Code', 'POB', 'Telephone', 'Fax',
       'Free_Tel', 'Handicap_Access', 'day_closed', 'Branch_Type', 'Open_Date',
       'Close_Date', 'Merge_Bank', 'Merge_Branch', 'X_Coordinate',
       'Y_Coordinate', 'ban_city', 'final_city'],
dtype='object')

In this database, we’ll mainly use the Bank_Name and City columns.

#Population

If we take a look at the CSV containing the population figures for each locality, we can see that the text is unreadable and the columns aren’t laid out properly. We have a problem.

image

I concluded that both the computer and IDE need to be told explicitly which encoding to use when reading the file. After some research, I determined that the relevant encoding was ISO-8859-8.

python
population = pd.read_csv('residents_in_israel_by_communities_and_age_groups.csv',encoding='ISO-8859-8')
population = population.applymap(lambda x: x.strip() if isinstance(x, str) else x)

population.columns
👇🏼
Index(['סמל_ישוב', 'שם_ישוב', 'סמל_נפה', 'נפה', 'קוד_לשכת_מנא', 'לשכת_מנא',
       'קוד_מועצה_אזורית', 'מועצה_אזורית', 'סהכ', 'גיל_0_5', 'גיל_6_18',
       'גיל_19_45', 'גיל_46_55', 'גיל_56_64', 'גיל_65_פלוס'],
dtype='object')

In this database, we’ll mainly use the שם_ישוב' and סהכ columns.

#Initial Exploration 🧠

#Banks

Besides running snifim.head(), I thought it would be nice to show the 10 largest banking companies in Israel.

First, I wrote a relatively simple function that sorts the df and returns the num_of_rows by size.

python
def shorten_df(df, iterate_row, num_of_rows, comment):
    sum_df = df[iterate_row].value_counts(ascending=True)
    length = len(sum_df)
    
    top_rows = sum_df[num_of_rows:].sort_values(ascending = False)
    
    if num_of_rows < length:
        other_rows = sum_df[:length - num_of_rows]
        remaining_row = pd.Series(other_rows.sum(), index=[comment])
    
        rows_plot = pd.concat([top_rows, remaining_row])
        return rows_plot
    return sum_df

Then I could easily see the 10 largest banking companies in Israel and display a chart:

python
banks = shorten_df(snifim, 'Bank_Name', 10, 'בנקים נוספים')

ax = sns.barplot(x = banks.index, y = banks.values)
ax.set_xticklabels(ax.get_xticklabels(),rotation = 90)

plt.show()

Although we managed to show the branches in a chart as I wanted, I ran into a problem displaying Hebrew that I still haven’t been able to solve. If anyone knows a solution, I’d love to hear it 😧.

Before moving on to the population database, I decided to check how many unique cities there are in this database:

python
ban_cities = set(snifim['City'])
print("Banks unique cities: {0}".format(len(ban_cities)))
👇🏼
"Banks unique cities: 161"

#Population

For the population data, I decided to run a basic groupby to get a sense of the scale and see whether the data made sense:

python
population.groupby('שם_ישוב')['סהכ'].sum().sort_values()
👇🏼
כפר עבודה              1
ידידה                  2
כרי דשא                3
איתנים                 4
אורנים                 5
                  ...   
ראשון לציון       277915
פתח תקווה         278645
חיפה              331402
תל אביב - יפו     573660
ירושלים          1056097
Name: סהכ, Length: 1264, dtype: int64

Here, too, I checked how many unique cities there are in the database. Notice the difference!

python
pop_cities = set(population['שם_ישוב'])
print("Population unique cities: {0}".format(len(pop_cities)))
👇🏼
"Population unique cities: 1264"

#Matching Columns 🔗

#What’s the Problem?

Both databases have a column referring to the city. The problem is that the city names aren’t written the same way. For example, we can see that “Nahariya” is written with two instances of the Hebrew letter yod in the bank database, but with just one in the population database 🤬.

python
'נהרייה' in ban_cities 👉🏼 True
'נהריה' in pop_cities 👉🏼 True

#The Levenshtein Distance Algorithm

I first encountered this algorithm while studying the Data Scientist with Python track on Datacamp. In addition to my explanation, I highly recommend learning about the mathematical background in this Medium article.

Given two str, we get a number representing the distance between them. What do I mean by distance? The number of the following operations we need to perform to get from one str to the other:

Levenshtein Distance
Taken from Devopedia

#So How Do We Calculate the Distance?

Using the process function, which we imported along with the fuzzywuzzy library, we can calculate the distance between words. In this example (copied from Datacamp), we want to find the element in the strOptions array that is closest to str2Match:

python
str2Match = "apple inc"
strOptions = ("Apple Inc.", "apple park", "apple incorporated", "iphone")
Ratios = process.extract(str2Match,strOptions)
print(Ratios)
👇🏼
[('Apple Inc.', 100), ('apple incorporated', 90), ('apple park', 67), ('iphone', 30)]

We got an array neatly containing the values from the strOptions array and their relative distance from str2Match. That’s it! Now we can return to our task and compare the cities in the databases 🥳.

#Calculating the Distance Between City Names

Now for the most fun part: applying the distance algorithm to the databases. I decided to match the bank database against the population database based on intuition and the difference in the number of cities.

python
ban_and_pop_city = pd.DataFrame(columns=['ban_city', 'pop_city', 'ratio'])

for ban_city in ban_cities:
    Ratios = process.extract(ban_city,pop_cities)[0]
    
    city_row = {
        'ban_city': ban_city,
        'pop_city': Ratios[0],
        'ratio': Ratios[1]
    }
    city_row = pd.DataFrame(city_row, index=[0])
    
    ban_and_pop_city = pd.concat([ban_and_pop_city, city_row], ignore_index = True)

#How Are the Match Percentages Distributed?

It was important to me to get an overview of how the matches were distributed, especially where they were concentrated.

python
sns.histplot(data=ban_and_pop_city, x="ratio", bins=20)
plt.show()

We’re in good shape: most cities have a 100 percent match. But our next task is to figure out which cities don’t.

#So Which Cities Have Low Match Scores?

At a glance, we can see that most discrepancies stem from cases like the example at the beginning: cities spelled with either one or two instances of yod. Let’s investigate which city names didn’t contain two instances of yod and weren’t a perfect match:

python
without_youd = ban_and_pop_city[ban_and_pop_city['ratio'] < 100]
without_youd = without_youd[~without_youd['ban_city'].str.contains('יי')]
indexban_citypop_cityratio
73Airport CityKiryat Ye’arim (institution)86
109Ben-Gurion AirportKerem Ben Zimra86
131Tel Aviv -YafoTel Aviv - Yafo96
139Arava Regional Council 54Kaukab Abu al-Hija86
140Northern Negev Highlands Regional Council 48Har Gilo86
153Ramla Area (not assigned to a locality)Ramla90

#Manual Changes

As we can see in the table above, there are edge cases we’ll need to edit manually. We haven’t eliminated manual work entirely; instead, we’ve moved from a manual workflow to a bird’s-eye review, saving most of the working hours in the process.

python
city_df = ban_and_pop_city
city_df['final_city'] = city_df['pop_city']

city_df.loc[city_df['ban_city'] == 'נמל תעופה בן-גוריון', 'final_city'] = 'לוד'

city_df[city_df['pop_city'].str.contains('נמל תעופה בן-גוריון')]

#Merging the Tables 😲

Now that we have a dictionary (city_df), we can use it to convert the city names in the snifim table so we can join it to the population table:

flowchart LR snifim --> id1[(city_df)] population --> id1[(city_df)] snifim <-..-> population

#In the population Table

python
population = population.merge(
  city_df[['pop_city','final_city']], 
  left_on='שם_ישוב', 
  right_on='pop_city', 
  how='left'
)

#In the snifim Table

python
snifim = snifim.merge(
  city_df[['ban_city', 'final_city']],
  left_on = 'City',
  right_on = 'ban_city',
  how = 'left'
)

#Analyzing the Findings 🤔

#Which Cities Have No Banks?

First of all, I decided to find out what percentage of cities have at least one bank:

python
population['has_bank'] = np.where(population['final_city'].isnull(), False, True)
length = len(population['final_city'])
no_banks = (population['has_bank'] == False).sum()

print("Percentage of cities with banks: {0:.0%}".format((length - no_banks) / length))
👇🏼
"Percentage of cities with banks: 13%"

87% of Israeli localities don’t have even a single bank branch. Surprising, right? Let’s show that in a chart:

python
pop_no_banks = population[population['has_bank'] == False]

sns.histplot(data=pop_no_banks, x="סהכ")
plt.axvline(x=pop_no_banks['סהכ'].median(), color='red')

plt.show()

#Distribution of Medium-to-Large Cities Without a Branch

As we can see from the chart above, it makes a lot of sense that localities with up to 5,000 residents wouldn’t have bank branches. I decided to examine the distribution of cities with no bank branches and more than 5,000 residents.

python
sns.histplot(
    data=pop_no_banks[pop_no_banks['סהכ'] > 5000], 
    x="סהכ",
    bins = 20,
    kde = True
)

#Distribution of the City Groups

The next step that interested me was comparing the distributions of cities with no bank branches against those with branches.

python
plt.xlim(0, 1000)
ax = sns.histplot(population, x="סהכ", hue="has_bank")

We made a simple histogram, just like in the previous section, but ran into a problem: the groups are on different scales.

#The Data Binning Process

#What Is It, Anyway?

To compare and examine the distributions of the groups, we’ll need to divide them up evenly. To do this, we’ll use a process called Binning. This process divides the values into equal ranges and assigns each value to the appropriate range. We’ll consider this a Data Preprocessing step.

#Dividing Up the Values

python
pop_bind = population.copy()
min_val = 2500
max_val = 75000
num_bins = 30

bins = np.linspace(
    min_val,
    max_val,
    num_bins
)

pop_bind['b_pop_y'] = pd.cut(pop_bind[pop_bind['has_bank']]['סהכ'], bins=bins, include_lowest=True, precision=0)
pop_bind['b_pop_n'] = pd.cut(pop_bind[~pop_bind['has_bank']]['סהכ'], bins=bins, include_lowest=True, precision=0)

#Combining the Columns

To compare the distributions, the next step is to combine the b_pop_y column with the b_pop_n column.

python
pop_bind['b_pop'] = pop_bind['b_pop_y'].fillna(pop_bind['b_pop_n'])

#Preparing the Visualization

The next step was to prepare a summary table, to_plot, that counts how many cities there are in each group.

python
to_plot = pop_bind.groupby(['b_pop','has_bank'])['סהכ'].count().reset_index()
b_pophas_bankTotal
0(2499.0, 5000.0]False57
1(2499.0, 5000.0]True6
2(5000.0, 7500.0]False18
3(5000.0, 7500.0]True8
4(7500.0, 10000.0]False12
5(7500.0, 10000.0]True10

#Visualization

Now, using a relatively simple barplot function, we can visually compare the concentration of cities with bank branches against those without.

python
sns.barplot(x = 'b_pop',
            y = 'סהכ',
            data = to_plot,
            hue = 'has_bank')
plt.xticks(rotation=90)

plt.show()

#The Relationship Between the Total Number of Bank Branches and Population

I was curious to check whether there is a relationship between the total number of bank branches and the number of residents in each locality.

python
snif = snifim.value_counts('final_city')
pop = population.groupby('שם_ישוב')['סהכ'].sum()

snif_pop = pd.concat([snif, pop], axis=1)
snif_pop.columns = ['snif', 'pop']
snif_pop = snif_pop[~snif_pop['snif'].isna()]
python
plt.xlim(0, 350000)
plt.ylim(0, 75)

sns.regplot(x="pop",
            y="snif", 
            data=snif_pop,
            ci=None)

plt.show()

#This Is Just the Beginning 😎

Now that we’ve learned about several distinctive technologies, we can take our research even further, expanding the dataset and presenting it in ways we hadn’t considered before.

The most important thing I want to leave you with is to remember, throughout the research, what message we want to convey and what we’re actually investigating. Otherwise, we’ll lose our way and won’t have a clear bottom line.

Back to top