Best way to join / merge by range in pandas

Question:

I’m frequently using pandas for merge (join) by using a range condition.

For instance if there are 2 dataframes:

A (A_id, A_value)

B (B_id,B_low, B_high, B_name)

which are big and approximately of the same size (let’s say 2M records each).

I would like to make an inner join between A and B, so A_value would be between B_low and B_high.

Using SQL syntax that would be:

SELECT *
FROM A,B
WHERE A_value between B_low and B_high

and that would be really easy, short and efficient.

Meanwhile in pandas the only way (that’s not using loops that I found), is by creating a dummy column in both tables, join on it (equivalent to cross-join) and then filter out unneeded rows. That sounds heavy and complex:

A['dummy'] = 1
B['dummy'] = 1
Temp = pd.merge(A,B,on='dummy')
Result = Temp[Temp.A_value.between(Temp.B_low,Temp.B_high)]

Another solution that I had is by applying on each of A value a search function on B by usingB[(x>=B.B_low) & (x<=B.B_high)] mask, but it sounds inefficient as well and might require index optimization.

Is there a more elegant and/or efficient way to perform this action?

Asked By: Dimgold

||

Answers:

Not sure that is more efficient, however you can use sql directly (from the module sqlite3 for instance) with pandas (inspired from this question) like:

conn = sqlite3.connect(":memory:") 
df2 = pd.DataFrame(np.random.randn(10, 5), columns=["col1", "col2", "col3", "col4", "col5"])
df1 = pd.DataFrame(np.random.randn(10, 5), columns=["col1", "col2", "col3", "col4", "col5"])
df1.to_sql("df1", conn, index=False)
df2.to_sql("df2", conn, index=False)
qry = "SELECT * FROM df1, df2 WHERE df1.col1 > 0 and df1.col1<0.5"
tt = pd.read_sql_query(qry,conn)

You can adapt the query as needed in your application

Answered By: Adonis

lets take a simple example:

df=pd.DataFrame([2,3,4,5,6],columns=['A'])

returns

    A
0   2
1   3
2   4
3   5
4   6

now lets define a second dataframe

df2=pd.DataFrame([1,6,2,3,5],columns=['B_low'])
df2['B_high']=[2,8,4,6,6]

results in

    B_low   B_high
0   1       2
1   6       8
2   2       4
3   3       6
4   5       6

here we go; and we want output to be index 3 and A value 5

df.where(df['A']>=df2['B_low']).where(df['A']<df2['B_high']).dropna()

results in

    A
3   5.0
Answered By: suvy

Setup
Consider the dataframes A and B

A = pd.DataFrame(dict(
        A_id=range(10),
        A_value=range(5, 105, 10)
    ))
B = pd.DataFrame(dict(
        B_id=range(5),
        B_low=[0, 30, 30, 46, 84],
        B_high=[10, 40, 50, 54, 84]
    ))

A

   A_id  A_value
0     0        5
1     1       15
2     2       25
3     3       35
4     4       45
5     5       55
6     6       65
7     7       75
8     8       85
9     9       95

B

   B_high  B_id  B_low
0      10     0      0
1      40     1     30
2      50     2     30
3      54     3     46
4      84     4     84

numpy
The ✌easiest✌ way is to use numpy broadcasting.
We look for every instance of A_value being greater than or equal to B_low while at the same time A_value is less than or equal to B_high.

a = A.A_value.values
bh = B.B_high.values
bl = B.B_low.values

i, j = np.where((a[:, None] >= bl) & (a[:, None] <= bh))

pd.concat([
    A.loc[i, :].reset_index(drop=True),
    B.loc[j, :].reset_index(drop=True)
], axis=1)

   A_id  A_value  B_high  B_id  B_low
0     0        5      10     0      0
1     3       35      40     1     30
2     3       35      50     2     30
3     4       45      50     2     30

To address the comments and give something akin to a left join, I appended the part of A that doesn’t match.

pd.concat([
    A.loc[i, :].reset_index(drop=True),
    B.loc[j, :].reset_index(drop=True)
], axis=1).append(
    A[~np.in1d(np.arange(len(A)), np.unique(i))],
    ignore_index=True, sort=False
)

    A_id  A_value  B_id  B_low  B_high
0      0        5   0.0    0.0    10.0
1      3       35   1.0   30.0    40.0
2      3       35   2.0   30.0    50.0
3      4       45   2.0   30.0    50.0
4      1       15   NaN    NaN     NaN
5      2       25   NaN    NaN     NaN
6      5       55   NaN    NaN     NaN
7      6       65   NaN    NaN     NaN
8      7       75   NaN    NaN     NaN
9      8       85   NaN    NaN     NaN
10     9       95   NaN    NaN     NaN
Answered By: piRSquared

I don’t know how efficient it is, but someone wrote a wrapper that allows you to use SQL syntax with pandas objects. That’s called pandasql. The documentation explicitly states that joins are supported. This might be at least easier to read since SQL syntax is very readable.

Answered By: baloo

I know this is an old question but for newcomers there is now the pandas.merge_asof function that performs join based on closest match.

In case you want to do a merge so that a column of one DataFrame (df_right) is between 2 columns of another DataFrame (df_left) you can do the following:

df_left = pd.DataFrame({
    "time_from": [1, 4, 10, 21],
    "time_to": [3, 7, 15, 27]
})

df_right = pd.DataFrame({
    "time": [2, 6, 16, 25]
})

df_left

  time_from time_to
0        1       3
1        4       7
2       10      15
3       21      27


df_right

  time
0    2
1    6
2   16
3   25

First, find matches of the right DataFrame that are closest but largest than the left boundary (time_from) of the left DataFrame:

merged = pd.merge_asof(
    left=df_1,
    right=df_2.rename(columns={"time": "candidate_match_1"}),
    left_on="time_from",
    right_on="candidate_match_1",
    direction="forward"
)

merged

  time_from time_to candidate_match_1
0        1       3                 2
1        4       7                 6
2        10      15               16
3        21      27               25

As you can see the candidate match in index 2 is wrongly matched, as 16 is not between 10 and 15.

Then, find matches of the right DataFrame that are closest but smaller than the right boundary (time_to) of the left DataFrame:

merged = pd.merge_asof(
    left=merged,
    right=df_2.rename(columns={"time": "candidate_match_2"}),
    left_on="time_to",
    right_on="candidate_match_2",
    direction="backward"
)

merged

  time_from time_to candidate_match_1   candidate_match_2
0        1        3                2                   2
1        4        7                6                   6
2        10      15               16                   6
3        21      27               25                  25

Finally, keep the matches where the candidate matches are the same, meaning that the value of the right DataFrame are between values of the 2 columns of the left DataFrame:

merged["match"] = None
merged.loc[merged["candidate_match_1"] == merged["candidate_match_2"], "match"] = 
    merged.loc[merged["candidate_match_1"] == merged["candidate_match_2"], "candidate_match_1"]

merged

  time_from time_to candidate_match_1   candidate_match_2   match
0        1       3                 2                   2       2
1        4       7                 6                   6       6
2        10     15                16                   6    None
3        21     27                25                   25     25
Answered By: Theodore Samuel

conditional_join from pyjanitor may be helpful in the abstraction/convenience;:

# pip install pyjanitor
import pandas as pd
import janitor

inner join

A.conditional_join(B, 
                   ('A_value', 'B_low', '>='), 
                   ('A_value', 'B_high', '<=')
                  )

   A_id  A_value  B_id  B_low  B_high
0     0        5     0      0      10
1     3       35     1     30      40
2     3       35     2     30      50
3     4       45     2     30      50

left join

A.conditional_join(
       B, 
       ('A_value', 'B_low', '>='), 
       ('A_value', 'B_high', '<='), 
       how = 'left'
    )

    A_id  A_value  B_id  B_low  B_high
0      0        5   0.0    0.0    10.0
1      1       15   NaN    NaN     NaN
2      2       25   NaN    NaN     NaN
3      3       35   1.0   30.0    40.0
4      3       35   2.0   30.0    50.0
5      4       45   2.0   30.0    50.0
6      5       55   NaN    NaN     NaN
7      6       65   NaN    NaN     NaN
8      7       75   NaN    NaN     NaN
9      8       85   NaN    NaN     NaN
10     9       95   NaN    NaN     NaN
Answered By: sammywemmy
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.