연관 분석(feat. Python)

지난 포스팅에서 연관분석(Association Analysis)이 무엇인지, 어떻게 결과를 도출하는지 에 대한 이론적인 방법을 살펴보았다.

이제 실제로 연관분석을 Python으로 하나씩 구현해 보자.

분석을 하려면 데이터가 필요하다. 이번 예제에서는 Instacart라는 온라인 기반 농작물 배송 서비스 회사에서 공개한 2017년 9월에 발생한 주문 및 제품 정보에 대한 데이터셋을 다운받아 사용 할 것이다.

데이터는 “The Instacart Online Grocery Shopping Dataset 2017” 링크를 통하면 다운 받을 수 있다. 다운받은 데이터는 압축을 푼 후에 작업 폴더에 옮겨둔다.
(예제에서는 작업할 Project 폴더에 “./Dataset/Instacart”라는 경로에 데이터를 넣어 두었다.)

먼저, 분석할 데이터를 확인해 보자.

필요한 모듈과 함수, 고정 변수 등을 미리 정의해 둔다.

import pandas as pd
import numpy as np
import sys
from itertools import combinations, groupby
from collections import Counter
from IPython.display import display

# 데이터 파일(객체)이 어느정도 사이즈(MB) 인지 확인 하는 함수.
def size(obj):
    return "{0:.2f} MB".format(sys.getsizeof(obj) / (1000 * 1000))

# 파일 저장 경로
path = "./Dataset/Instacart"

Pandas를 이용하여 주문 데이터를 읽어서 데이터의 사이즈와 차원을 확인하고 실제 데이터를 살펴 보기 위해 상위 5개 데이터를 확인해 본다.

orders = pd.read_csv(path + '/order_products__prior.csv')
print('orders -- dimensions: {0};   size: {1}'.format(orders.shape, size(orders)))
display(orders.head())
orders -- dimensions: (32434489, 4);   size: 1037.90 MB
order_idproduct_idadd_to_cart_orderreordered
023312011
122898521
22932730
324591841
423003550
주문 데이터 상위 5개

주문 데이터는 4개의 차원으로 약 3천만 건의 주문 정보를 담고 있으며, 총 데이터의 크기는 1GB라는 것을 알 수 있다. 4개의 차원은 주문번호, 상품번호, 카트에 담긴 순서, 재(추가)주문 상태를 나타내고 있다. 여기서 연관규칙을 찾을 때 필요한 데이터는 주문번호와 상품번호만 있으면 되기 때문에 주문번호를 인덱스로 하고 상품번호를 Value로하는 Series로 변환한다.

orders = orders.set_index('order_id')['product_id'].rename('item_id')
print('dimensions: {0};   size: {1};   unique_orders: {2};   unique_items: {3}'
      .format(orders.shape, size(orders), len(orders.index.unique()), len(orders.value_counts())))
dimensions: (32434489,);   size: 518.95 MB;   unique_orders: 3214874;   unique_items: 49677

차원이 4개에서 1개로 줄었고, 사이즈도 1GB에서 절반정도 줄어든 518MB가 되었다.

데이터가 준비 되었으니, 연관 규칙을 찾기 위한 프로그램(함수)들을 정의해보자.

연관규칙을 찾기 위해서는 지지도, 신뢰도, 향상도 지표를 확인하여 규칙의 효용성을 확인 해야 한다. 이 3개 지표를 계산해 내기 위한 함수를 먼저 정의한다.

# 단일 품목이나 품목 집합에 대한 빈도수를 반환한다.
def freq(iterable):
    if type(iterable) == pd.core.series.Series:
        return iterable.value_counts().rename("freq")
    else: 
        return pd.Series(Counter(iterable)).rename("freq")
    
# 고유한 주문번호 갯수를 반환한다.
def order_count(order_item):
    return len(set(order_item.index))

# 한번에 한 품목 집합을 생성하는 generator를 반환한다.
def get_item_pairs(order_item):
    order_item = order_item.reset_index().values
    for order_id, order_object in groupby(order_item, lambda x: x[0]):
        item_list = [item[1] for item in order_object]
              
        for item_pair in combinations(item_list, 2):
            yield item_pair            

# 품목에 대한 빈도수와 지지도를 반환한다.
def merge_item_stats(item_pairs, item_stats):
    return (item_pairs
                .merge(item_stats.rename(columns={'freq': 'freqA', 'support': 'supportA'}), left_on='item_A', right_index=True)
                .merge(item_stats.rename(columns={'freq': 'freqB', 'support': 'supportB'}), left_on='item_B', right_index=True))

# 품목 이름을 반환한다.
def merge_item_name(rules, item_name):
    columns = ['itemA','itemB','freqAB','supportAB','freqA','supportA','freqB','supportB', 
               'confidenceAtoB','confidenceBtoA','lift']
    rules = (rules
                .merge(item_name.rename(columns={'item_name': 'itemA'}), left_on='item_A', right_on='item_id')
                .merge(item_name.rename(columns={'item_name': 'itemB'}), left_on='item_B', right_on='item_id'))
    return rules[columns]

다음으로, 실제 규칙을 찾기 위해 위 지표를 구하는 함수들을 이용하여, 연관 규칙을 찾는 함수를 정의 한다.

# 미리 준비한 주문 정보(주문번호를 인덱스로 하고 상품번호를 Value로하는 Series)와 최소 지지도를 입력받아 연관 규칙을 반환한다.
def association_rules(order_item, min_support):

    print("Starting order_item: {:22d}".format(len(order_item)))

    # 빈도수와 지지도를 계산한다.
    item_stats             = freq(order_item).to_frame("freq")
    item_stats['support']  = item_stats['freq'] / order_count(order_item) * 100

    # 최소 지지도를 만족하지 못하는 품목은 제외한다. 
    qualifying_items       = item_stats[item_stats['support'] >= min_support].index
    order_item             = order_item[order_item.isin(qualifying_items)]

    print("Items with support >= {}: {:15d}".format(min_support, len(qualifying_items)))
    print("Remaining order_item: {:21d}".format(len(order_item)))

    # 2개 미만의 주문 정보는 제외한다.
    order_size             = freq(order_item.index)
    qualifying_orders      = order_size[order_size >= 2].index
    order_item             = order_item[order_item.index.isin(qualifying_orders)]

    print("Remaining orders with 2+ items: {:11d}".format(len(qualifying_orders)))
    print("Remaining order_item: {:21d}".format(len(order_item)))

    # 빈도수와 지지도를 다시 계산한다.
    item_stats             = freq(order_item).to_frame("freq")
    item_stats['support']  = item_stats['freq'] / order_count(order_item) * 100

    # 품목 집합에 대한 generator를 생성한다.
    item_pair_gen          = get_item_pairs(order_item)

    # 품목 집합의 빈도수와 지지도를 계산한다. 
    item_pairs              = freq(item_pair_gen).to_frame("freqAB")
    item_pairs['supportAB'] = item_pairs['freqAB'] / len(qualifying_orders) * 100

    print("Item pairs: {:31d}".format(len(item_pairs)))

    # 최소 지지도를 만족하지 못하는 품목 집합을 제외한다.
    item_pairs              = item_pairs[item_pairs['supportAB'] >= min_support]

    print("Item pairs with support >= {}: {:10d}\n".format(min_support, len(item_pairs)))

    # 계산된 연관 규칙을 계산된 지표들과 함께 테이블로 만든다.
    item_pairs = item_pairs.reset_index().rename(columns={'level_0': 'item_A', 'level_1': 'item_B'})
    item_pairs = merge_item_stats(item_pairs, item_stats)
    
    item_pairs['confidenceAtoB'] = item_pairs['supportAB'] / item_pairs['supportA']
    item_pairs['confidenceBtoA'] = item_pairs['supportAB'] / item_pairs['supportB']
    item_pairs['lift']           = item_pairs['supportAB'] / (item_pairs['supportA'] * item_pairs['supportB'])
    
    # 향상도를 내림차순으로 정렬하여 연관 규칙 결과를 반환한다.
    return item_pairs.sort_values('lift', ascending=False)

연관 규칙을 찾기 위한 데이터와 프로그램(함수) 준비가 완료 되었다.

연관규칙을 찾아보자.

%%time
rules = association_rules(orders, 0.01)
Starting order_item:               32434489
Items with support >= 0.01:           10906
Remaining order_item:              29843570
Remaining orders with 2+ items:     3013325
Remaining order_item:              29662716
Item pairs:                        30622410
Item pairs with support >= 0.01:      48751

Wall time: 6min 24s

출력된 결과를 확인해보자. 약 3천만 건의 주문 정보에서 최소 지지도 0.01를 넘는 약 4만 8천건의 연관 규칙을 찾아 내었고, 연관 규칙을 만들어 내는 데 걸린 시간은 6분 24초가 걸렸다는 것을 알 수 있다.

찾은 결과를 출력해 보자.

# 품목 ID를 보기 좋게 하기 위해서 품목 이름으로 바꿔준다.
item_name   = pd.read_csv(path + '/products.csv')
item_name   = item_name.rename(columns={'product_id':'item_id', 'product_name':'item_name'})
rules_final = merge_item_name(rules, item_name).sort_values('lift', ascending=False)
display(rules_final)
연관 규칙 결과 테이블

출력된 연관 규칙 테이블을 보면 다소 복잡해 보일 수 있으나, 맨 마지막 열의 향상도(lift)를 보면 품목 간의 관계를 확인 할 수 있다.

  • lift = 1, 품목간의 관계 없다.
    *예: 우연히, 같이 사게되는 경우
  • lift > 1, 품목간의 긍정적인 관계가 있다.
    *예: 같이 사는 경우
  • lift < 1, 품목간의 부정적인 관계가 있다.
    *예: 같이 사지 않는 경우

출력된 향상도를 이용하여 결과를 분석해보자.

먼저, 품목 간의 긍정적인 관계인 향상도가 1 보다 큰 결과를 살펴 보자.

  1. 코티지 치즈는 블루베리 아사이 맛과 딸기 치아 맛을 같이 구매한다.
  2. 고양이 먹이는 치킨 맛과 칠면조 맛을 같이 구매한다.
  3. 요거트는 믹스 베리 맛과 사과 블루베리 맛을 같이 구매한다.

위 결과를 토대로 생각해 보면, 대부분 구입한 한 가지 품목에 대해서 다른 맛을 내는 같은 품목을 같이 구입한다는 것을 알 수 있다.

다음으로, 품목 간의 부정적인 관계인 향상도가 1보다 작은 결과를 보자.

  1. 유기농 바나나를 사는 경우 일반 바나나는 사지 않는다.
  2. 일반 품종의 아보카도를 구입한 경우 하스 아보카도를 구입하지 않는다.
  3. 유기농 딸기를 사는 경우 일반 딸기를 사지 않는다.

이번 결과에서는 구입한 한 가지 품목에 대해서 다른 생산과정 혹은 영양구성의 같은 품목은 같이 구입하지 않는다는 것을 알 수 있다.

이전 포스팅에서 언급했듯이, 단순히 나열된 구매 정보만으로는 확인 할 수 없었던 규칙들을 연관 분석을 통해서 알아 낼 수 있게 되었다. 이러한 정보를 바탕으로 고객들에게 제품을 추천 한다거나 상품의 배열을 바꿔 줄 수 있다면 상품 판매에 의미 있는 결과를 얻을 수 있을 거라 생각한다.

너와 나의 연결고리(feat. 연관분석)

“너와! 나의! 연결! 고리! 이건! 우리! 안의! 소리!”

이 노래 가사는 2014년 한국 힙합씬 최대의 이슈 트랙중 하나인 일리네어 레코즈의 연결고리 라는 노래 중 메인 플로우의 한 부분이다.

이 노래의 가사 절반 이상이 저 가사로 가득 차있다. 아마도 연관관계가 없어보이는 너와 나를 이어주는 공감대을 찾고 싶어 했던 것 아니였을까 싶다.

이렇듯, 우리는 언제나 연관관계가 없어보이는 것들 사이에서 접점인 연결고리를 찾고 싶어 한다. 주변 사람들에 대해서도 물론이고 자신이 가지고 있는 물건, 취향들 심지어는 행동까지 연관지어 생각한다.

특히나, 최근에는 디지털 뉴딜이니 데이터 댐이니 하는 이슈로 인해서 데이터에 관심이 집중되고 있고, 이러한 영향으로 자신이 가지고 있는 다양한 데이터 속에서 연관된 규칙을 찾고자하는 사람들이 늘어나고 있다.

게다가 요즘 핫한 AI라는 것이 무의미해 보이는 데이터 속에서 의미 있는 패턴(연관규칙)을 저절로 찾아 준다고 하니, 꼭 한번 해보고 싶을 것이다.

그렇다면, 연관 분석(Association Analysis)이 무엇인지 개념부터 사용법까지 하나씩 알아보자.

경영학에서는 장바구니 분석(Market Basket Analysis)으로 알려진 연관분석은 ‘전체 영역에서 서로 다른 두 아이템 집합이 어떠한 조건과 반응의 형태(if – then)로 발생하는가’ 를 일련의 규칙으로 생성하는 방법(혹은 알고리즘)이다.

예를 들어 ‘아메리카노를 마시는 손님중 10%가 브라우니를 먹는다.’라고 하거나 ‘샌드위치를 먹는 고객의 30%가 탄산수를 함께 마신다.’ 과 같은 식의 결론을 내는 것이다.

이런 규칙성을 많이 사용하는 분야는 대표적으로 판매업이 있다. 고객이 어떤 상품을 고르면 그 상품을 구매한 다른 고객들이 선택한 다른 상품들을 제안해 준다던지 하는 컨텐츠 기반 추천의 기본이 되는 것이다.

그럼 이제, 이러한 연관 분석을 하기 위해 사용되는 데이터부터 살펴보자, 동네 카페 매출이력(transaction)이 다음과 같이 주어졌다고 생각해보자.

거래번호품목
1154아메리카노
아이스 카페모카
허니브래드
블루베리 치즈케이크
치즈 케이크
1155카라멜 마키아또
브라우니
크림치즈 베이글
1156아메리카노
탄산수
크랜베리 치킨 샌드위치
1157아메리카노
카라멜 마끼아또
허니 브래드

위 데이터에서 각 고객이 구입한 품목들을 확인 할 수 있는데, 데이터의 연관규칙을 찾기 위해서는 다음과 같이 희소행렬(Sparse Matrix)로 나타내 주면 좋다.

아메리카노아이스 카페모카카라멜 마키아또탄산수블루베리 치즈 케이크치즈 케이크허니 브래드브라우니크림 치즈 베이글크랜베리 치킨 샌드위치
11541100111000
11550010000110
11561001000001
11571010001000

이제 연관규칙을 찾을 준비는 되었다. 조건과 반응 형태로 규칙을 만들면 되는데, 다음과 같이 규칙을 만들 수 있다.

  • 아메리카노를 마시는 사람은 아이스 카페모카도 마신다.
  • 아메리카노를 마시는 사람은 치즈케이크도 먹는다.
  • 아메리카노를 마시는 사람은 허니 브래드도 먹는다.
  • 아메리카노를 마시는 ….

“어?! 뭔가 이상하게 생각 했던 것과는 다르다. 뭔가 그럴듯하게 맞아 떨어지는 규칙은 아니네?” 라고 생각 들것이다.

그 생각이 맞다. 이런식으로 규칙을 만든다면, 주어진 데이터에서 무수히 많은 규칙을 만들어 낼 수 있다. 우리는 이 많은 규칙들 가운데서도 좋은 규칙을 찾아 내는 방법을 알아야 한다.

그렇다면, 좋은 규칙은 어떻게 찾을까?

좋은 규칙을 판단하기 위해서 규칙의 효용성을 나타내는 지표는 지지도(Support)신뢰도(Confidence) 그리고 향상도(Lift)이다. 이 3가지 지표를 모두 반영해 규칙의 효용성을 판단하고 좋은 규칙을 선택해야 한다.

이 3가지 지표를 구하는 방법은 다음과 같다.

  1. 지지도(Support)
    • 전체 거래 중 품목 A와 품목 B를 동시에 포함하는 거래의 비율로 정의한다.
지지도 공식
  1. 신뢰도(Confidence)
    • 품목 A를 포함한 거래 중에서 품목 A와 품목 B가 같이 포함될 활률이다. 연관성의 정도를 파악할 수 있다.
신뢰도 공식
  1. 향상도(Lift)
    • A가 구매되지 않았을 때 품목 B의 구매확률에 비해 A가 구매됐을 때 품목 B의 구매활률의 증가 비(Rate)이다. 연관규칙 A -> B는 품목 A와 품목 B의 구매가 서로 관련이 없는 경우에 향상도가 1이 된다.
향상도 공식

이제 좋은 규칙을 고르는 법도 알았으니 실제로 제대로 된 규칙을 찾아 보자.

가능한 모든 경우의 수를 탐색하여 지지도, 신뢰도, 향상도가 높은 규칙을 찾을 수 있다면 가장 이상적일 것이다. 데이터가 작으면 가능할지도 모르겠지만, 데이터가 증가 하면 할수록 계산에 드는 속도도 기하급수적으로 늘어나게 된다.

(알고리즘의 데이터 증가에 대한 복잡도는 n^2이다.)

이러한 이유로 자주 발생하는 아이템에 대해서 빈발 집합(Frequent item sets)을 만들고 이 집합만 고려하는 규칙을 만드는 것이 효율적이다.

예를들어 품목 탄산수의 지지도, 즉 P(탄산수)가 0.1로 나타났다고 보면, 탄산수를 포함한 품목의 지지도는 아무리 높아도 0.1을 넘지 못할 것이다. 탄산수가 단독으로 등장할 확률인 P(탄산수)는 아무리 크게 나와도 P(탄산수, 아메리카노)를 넘지 못 할 것이기 때문이다. 이때 {탄산수, 아메리카노}는 {탄산수}, {아메리카노}의 초월집합(Super set)이라고 한다.

임의의 품목 집합의 지지도가 일정 기준을 넘지 못한다면 해당 집합의 초월 집합의 지지도는 그 기준보다 무조건 더 작을 것이기 때문에 유용한 규칙으로 선택 될 수 없다. 이처럼 최소 지지도 요건을 만족하지 못하는 품목 집합의 규칙을 애당초 제외하면 계산의 효율성을 높일 수 있다.

빈발관계 탐색

그럼 다시 연관 규칙분석을 수행해보자. 최소 지지도 요건을 0.3으로 설정했다고 하자. 그럼 각 품목의 지지도는 아메리카노(0.75), 아이스 카페모카(0.25), 카라멜 마키아또(0.5), 탄산수(0.25), 블루베리 치즈 케이크(0.25), 치즈 케이크(0.25), 허니 브래드(0.5), 브라우니(0.25), 크림 치즈 베이글(0.25), 크랜베리 치킨 샌드위치(0.25)로 나타낼 수 있다.

여기서 최소 지지도를 만족하지 못하는 것들을 제외하여 2개짜리 품목 집합을 생성하면, 다음과 같다.

아메리카노카라멜 마키아또허니 브래드
아메리카노00.250.5
카라멜 마키아또00.25
허니 브래드0

여기서도 마찬가지로 품목 {아메리카노, 카라멜 마키아또}와 {카라멜 마키아또, 허니 브래드}는 최소 지지도 0.3을 만족하지 못하였으니 제외 한다. 이런 방식으로 더 이상 최소 지지를 만족하지 못하는 집합이 없을 때까지 품목 집합의 크기를 1씩 증가 시켜가면서 반복 수행 한다.

이러한 방식으로 구한 동네 카페 매출이력 4건을 토대로 가장 좋은(강한) 연관 규칙은 “아메리카노를 마시는 사람은 허니 브래드도 동시에 먹는다.”가 되는 셈이다.

조건절결과절지지도신뢰도향상도
아메리카노허니 브래드0.50.671.33

여기까지, 연관관계 분석 방법에 대해서 알아보았고, 연관 분석을 통해서 그냥 눈으로만 봐서는 알 수 없었던 거래 데이터에서 규칙을 찾아 낼 수 있었다.

이 외에도 연관 분석 방법은 거래 데이터 뿐만 아니라 순차적이고 반복적으로 발생하는 대부분의 데이터에 적용 할 수 있다.

  • 운영하는 홈페이지에서 어떤 순서로 컨텐츠를 클릭 하였는가?
  • 특정 직무의 사람들은 어떤 순서로 일을 하는가?
  • 특정 문서에 출현한 단어들이 어떤 유의미한 의미로 요약 될 수 있는가?

위의 예 처럼 생각해 볼 수록 많은 응용점들이 있을 거라 생각한다. 한번 자신이 가지고 있는 데이터를 이용하여 각 특성들이 어떤 연결고리가 있는지 분석해 볼 수 있을 것 같다.