Posts 뽐뿌 특가 데이터 전처리 하기
Post
Cancel

뽐뿌 특가 데이터 전처리 하기

특가 정보에 관심이 많은 사람으로써 특가 데이터 분석을 위해 뽐뿌의 특가 게시판을 크롤링 하여 특가 데이터를 확보하였고, 그 데이터를 전처리하였다. 특가 데이터 분석1)데이터 확보(크롤링) 2)데이터 전처리 3)특가 데이터 분석 4)카테고리 예측 모델링순으로 진행된다.

1. 개요

데이터 분석을 시작하기 전에, 정확한 데이터 분석을 위해 전처리 과정이 필요하다. 데이터 분석가 업무의 80%는 데이터 전처리라는 우스갯소리를 할 정도로 굉장히 많은 시간이 들어가고 많은 고민을 하는것이 데이터 전처리 과정이다. 이번 뽐뿌 특가 데이터 분석에도 데이터 전처리는 빠질수 없는 과정으로 분석을 진행하기 용이하기 데이터 전처리를 진행했다.

2. 데이터 전처리 과정

데이터 전처리는 아래의 3개 과정을 통해 진행되었다.

  1. 특성 추출: 게시물 제목에서 판매채널, 제품 가격, 배송비 정보를 추출했다.
  2. 데이터 정제: 추출한 특성에서 결측치, 이상치, 정합성 확인, 통합 등을 처리하여 데이터의 일관성과 정확성을 높였다
  3. 데이터 변환: 문자열을 숫자로 변환하는 등의 필요한 형태로 데이터를 변환했다.

3. 결론

데이터 전처리를 통해 특가 게시물의 핵심 정보인 판매 채널, 제품 금액, 배송비, 그리고 키워드를 추출하였다. 이러한 과정에서 판매 채널의 통합, 금액 정보의 정제 및 키워드의 최적화 작업으로 데이터 품질을 향상시켰다. 이렇게 향상된 데이터는 특가를 찾는 사용자들의 관심도와 반응을 파악하는 데 큰 도움을 제공할 것이다. 따라서, 전처리된 데이터는 특가 정보의 특성과 트렌드를 더욱 명확하게 보여주며, 사용자들이 더 현명한 소비 결정을 내릴 수 있도록 도와줄 것이다.

데이터 전처리 코드


1. Package and Data load

데이터 전처리 전에 패키지를 임포트하고 데이터를 로드하여 데이터를 확인한다. 또한, 컬럼의 정의는 아래와 같다.

1
2
3
4
5
6
7
import pandas as pd
import numpy as np
import re

from tqdm import tqdm
from kiwipiepy import Kiwi
from datetime import datetime
1
2
df = pd.read_csv('./datas/2023-06-30 22:27:20.666568_117980개.csv')
df.head(2)
 item_nowritertitleendcommentdaterecommendoppositeviewcategoryURLpophot
0470673Ko**[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)True823.06.29 20:39:22117125[의류/잡화]https://www.ppomppu.co.kr/zboard/view.php?id=p…FalseFalse
1470672**[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97…True1523.06.29 20:03:40009811[가전/가구]https://www.ppomppu.co.kr/zboard/view.php?id=p…FalseFalse
Column설명
item_no게시물 번호
Author작성자
Title게시물 제목
end특가 종료 여부
Comments댓글 수
Date게시 날짜
recommend추천수
opposite반대수
view조회수
Category특가 제품이 속한 카테고리
URLURL
pop인기 게시물 여부
hot핫 게시물 여부

2. 데이터 요약 정보

데이터 전처리의 시작으로 크롤링된 데이터의 요약자료를 보았다. 눈에 뜨는것은 댓글이 1401개 있는 게시물인데, 확인해보니 P11 가성비 태블릿이 역대급 특가였으나, 실제로는 가격 오류로 인한것이였으며, 주문 제품은 모두 취소처리된 게시물이다.

1
df.describe()
 item_nocommentrecommendoppositeview
count117980.000000117980.000000117980.000000117980.000000117980.000000
mean387483.18139530.8780476.6369470.20739114840.913062
std48189.47658732.62521013.6710751.47610710264.550307
min305204.0000000.0000000.0000000.000000731.000000
25%345619.75000011.0000000.0000000.0000007676.000000
50%387378.50000021.0000002.0000000.00000012169.000000
75%428465.25000039.0000007.0000000.00000019035.000000
max470673.0000001401.000000582.000000141.000000427882.000000
1
df.info()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 117980 entries, 0 to 117979
Data columns (total 13 columns):
 #   Column     Non-Null Count   Dtype 
---  ------     --------------   ----- 
 0   item_no    117980 non-null  int64 
 1   writer     117980 non-null  object
 2   title      117980 non-null  object
 3   end        117980 non-null  bool  
 4   comment    117980 non-null  int64 
 5   date       117980 non-null  object
 6   recommend  117980 non-null  int64 
 7   opposite   117980 non-null  int64 
 8   view       117980 non-null  int64 
 9   category   117980 non-null  object
 10  URL        117980 non-null  object
 11  pop        117980 non-null  bool  
 12  hot        117980 non-null  bool  
dtypes: bool(3), int64(5), object(5)
memory usage: 9.3+ MB

3. 제목에서 판매 채널, 가격 정보 가져오기

뽐뿌게시판은 제목 맨앞에 판매 채널, 맨 뒤에 가격을 적는것을 규칙으로 하고 있으며, 이번에는 해당 규칙을 활용해 판매 채널과 가격을 제목에서 추출했다. 다만, 게시물 작성 규칙을 지키 않아도 게시글은 작성이 되므로 예외 처리된 데이터도 있을것으로 판단되며, 예외 처리되는 데이터를 줄여야 한다. 전체 특가 데이터는 117,980개로 Null값은 없는것으로 보인다. 또한 int형태와 object 형태로만 되어있어서 데이터를 정리할 필요가 있어보인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def extract_sales_channel_and_price(title):
    """ 특가 게시물 제목에서 가격, 판매채널 추출
    Args:
        title - 특가 게시물 제목
    Returns:
        str : 아래의 데이터를 가진 str형식 return
            - sales_channel : 특가 판매 채널 (e.g 지마켓)
            - price : 제품/배송비 가격
    """
    # 문자열에서 [...] 혹은 (...) 형태의 구성을 찾아 추출
    pattern = r"\[([^\]]+)\]|\(([^\)]+)\)"
    
    # 가격, 판매채널 추출
    matches = re.findall(pattern, title)
    
    # 판매채널
    sales_channel = matches[0][0] or matches[0][1] if matches else "unknown"
    sales_channel = sales_channel.strip()
    
    # 가격
    price = matches[-1][0] or matches[-1][1] if matches else "unknown"  # Return the last match
    price = price.strip()
    
    return sales_channel, price

# 제목에서 판매채널과 가격 추출
df['sales_channel'], df['price'] = zip(*df['title'].map(extract_sales_channel_and_price))
df[['title', 'sales_channel', 'price']].head(2)
 titlesales_channelprice
0[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)cj온스타일21,600원/무료
1[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97…G마켓606,970/무료

4. 제품/배송비 가격 정보에서 제품 가격과 배송비 정보를 분리

price 컬럼을 보면 제품 가격과 배송비가 같이 적혀있으므로, 이를 다시 분리해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def split_price(price):
    """ 제품/배송비 가격에서 제품 가격과 배송비 분리
    Args:
        price - 제품/배송비 가격
    Returns:
        str : 아래의 데이터를 가진 str형식 return
            - product_price : 특가 제품 가격
            - shipping_cost : 배송비
    """
    # price에 배송비가 없는 경우도 있으므로, 있으면 제품가격과 배송비, 없으면 제품가격과 unknown으로 리턴
    if "/" in price:
        product_price, shipping_cost = price.split("/", 1)  # Split into at most 2 parts
    else:
        product_price = price
        shipping_cost = "unknown"
    return product_price, shipping_cost

# 가격에서 제품 가격과 배송비 구별
df['product_price'], df['shipping_cost'] = zip(*df['price'].map(split_price))
df[['title', 'sales_channel', 'price', 'product_price', 'shipping_cost']].head(2)
 titlesales_channelpriceproduct_priceshipping_cost
0[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)cj온스타일21,600원/무료21,600원무료
1[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97…G마켓606,970/무료606,970무료

5. 판매 채널 통합

판매 채널의 갯수는 5,992개이지만 사람이 직접 적는것으로 같은 판매 채널이여도 약어로 적거나 별칭 등 다르게 적을 수 있어서 동일한 채널이라면 하나의 판매채널로 통합한다. 정리하여 5,992개에서 3,751개로 38% 가량 통합하였다.

1
df["sales_channel"].value_counts()
1
2
3
4
5
6
7
8
9
10
11
12
G마켓        14920
11번가       12163
옥션         11152
위메프         9173
티몬          8857
           ...  
롯데온앱           1
쎄제이            1
파파존스           1
옥션스마일클럽        1
NS쇼핑몰          1
Name: sales_channel, Length: 5992, dtype: int64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def channel_merge(df, channel, change_channel):
    """ 판매 채널 명
    Args:
        df - 특가 게시물 Dataframe
        channel - 변경 전 채널 이름 
        change_channel - 변경될 채널 이름
        
    Returns:
        Dataframe : 통합 채널명으로 변경된 DataFrame
    """
    
    # 통합될 채널 이름 찾기
    temp = df[df["sales_channel"].str.contains(channel, case=False)]
    # 변경할 index 저장
    change_value_idx = temp.index
    # index를 기준으로 변경 될 채널이름으로 변경
    df.loc[change_value_idx, "sales_channel"] = change_channel
    return df

channel_dict = {"네이버":["네이버", "스마트스토어", "스토어팜", "원쁠딜"],
                "11번가":["11번가", "11st", "11마존", "쇼킹딜"],
                "신세계":["신세계", "SSG"],
                "하이마트":["하이마트"],
                "롯데":["롯데", "칠성몰"],
                "카카오":["카카오", "톡딜", "카톡", "톡스토어"],
                "티몬":["티몬", "tmon", "티켓몬스터"],
                "CJ":["CJ"],
                "그립":["그립", "grip"],
                "우체국":["우체국"],
                "쿠팡":["쿠팡", "ㅋㅍ"],
                "보고":["보고","vogo"],
                "인터파크":["인터파크"],
                "AK몰":["ak"],
                "큐텐":["큐텐", "Qo", "큐10", "Q10"],
                "Quube":["Quube"],
                "GS":["gs", "나만의 냉장고"],
                "지마켓/옥션":["지마켓", "옥션","지/옥", "쥐마켓", "g마켓", "g9", "지9", "지구", "gmarket", "지옥", "옥베이"],
                "SK":["sk"],
                "아이허브":["ih"],
                "KT":["kt"],
                "Hmall":["hm", "H패", "현대몰", "h몰"],
                "홈플러스":["홈플"],
                "NS홈쇼핑":["ns"],
                "이마트":["이마트몰"],
                "메가마트":["메가마트"],
                "오늘의집":["오늘의"],
                "전자랜드":["전자랜드"],
                "나이키":["나이키"],
                "예스24":["yes"],
                "코스트코":["코스트코"],
                "Steam":["Steam", "스팀", "Indiegala"],
                "아디다스":["아디다스"],
                "홈앤쇼핑":["홈&"],
                "삼성":["삼성"],
                "신한":["신한"],
                "크록스":["크록스"],
                "국민":["국민", "국카"],
                "다이슨":["다이슨"],
                "리복":["리복"],
                "LF스퀘어몰":["LF"],
                "K쇼핑":["K쇼핑"],
                "CGV":["CGV"],
                "배달의민족":["배민"],
                "동원몰":["동원"],
                "탑텐":["탑텐"],
                "위메프":["위메프"],
                "unknown":["종료", "끌어올림", "끌올", "무배", "다양", "공홈"]
               }

for change_channel, channels in channel_dict.items():
    for channel in channels:
        df = channel_merge(df, channel, change_channel)

df["sales_channel"].value_counts()
1
2
3
4
5
6
7
8
9
10
11
12
지마켓/옥션         35548
11번가           14834
위메프             9247
티몬              9009
네이버             6828
               ...  
110만원대 /무료         1
올렛츠                1
Folderstyle        1
모요/스마텔             1
리브메이트앱             1
Name: sales_channel, Length: 3751, dtype: int64

6. 제품 가격의 자료형을 Str에서 Float형으로 변환함

가격 데이터 분석에 용이하기 위해 unknown을 넘파이를 이용하여 NaN으로 변환하고, 그 외 자료는 글씨를 제외하고 숫자만 남긴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def convert_price_to_int(price):
    """ 제품 가격 자료형 변환
    Args:
        price - : Str 형식의 제품 가격
    Returns:
        Nan : unknown일때
        int : Int형 가격
    """
    
    # unknown은 NaN값
    if price == "unknown":
        return np.NaN
    # 그 외 가격은 "원", "," "."을 삭제한 숫자형
    else:
        cleaned_price = price.replace("원", "").replace(",", "").replace(".", "").strip()
        if cleaned_price.isdigit():
            return int(cleaned_price)
        else:
            return np.NaN
1
2
df['product_price'] = df['product_price'].map(convert_price_to_int)
df[['title', 'sales_channel', 'price', 'product_price', 'shipping_cost']].head()
 titlesales_channelpriceproduct_priceshipping_cost
0[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)CJ21,600원/무료21,600.0무료
1[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A)지마켓/옥션606,970/무료606,970.0무료
2[네이버] 국내산 1등급 소고기 등심 200G (9,900원/4000원)네이버9,900원/4000원9,900.04000원
3[NS몰] 데이즈온 오한진 초임계 알티지 오메가3 비타플러스 3개월NS홈쇼핑9,500원/무료9,500.0무료
4[옥션] 리큐 진한겔 꿉꿉한냄새 싹 2.1L X 6 [20,930/무료배송]지마켓/옥션20,930/무료배송20,930.0무료배송

7. 제품 가격 아웃라이어 확인 및 NaN 처리

특가 게시물 등록시 규칙을 지키지 않거나 가격을 여러번 적어 잘못 추출된 가격을 삭제하기 위해 상위 0.0014를 nan 값 처리 했다.

1
2
3
4
5
# 상위 0.0014 제외
cut = df["product_price"].quantile(0.9986)
print(f"기준 가격 {cut}")
temp = df[df["product_price"] > cut]
temp.sort_values("product_price", ascending=False)[["product_price", "price"]]
1
기준 가격 6050509.500005719
 product_priceprice
1089367.495909e+17749,590,887,040,974,160/무료
1019949.600097e+1496,000원,96,500원,97,000원/무료,무료,5장이상 구매시 무료
890443.570020e+1435700,19800,18990/2500,3000
634223.083063e+1430,830원,62,770원,33,620원/무료
764812.590028e+1425900,27900,30900/무료배송
1005769.701960e+06970,1,960/2500
447538.891700e+06889,1700/무료, 카드할인 791,360원, 자급제, 로켓배송
803687.690000e+06769,000,0
348727.324760e+067324,760/무료
967896.510000e+06651,0000/배송
1
2
top_index = temp.index
df.loc[top_index, "product_price"] = np.nan

8. 배송비 정합성 확인 및 NaN 처리

배송비에 대한 단어들은 “무료”, “무배” 등 여러 가지로 작성되어 있어서 무료배송을 뜻하는 단어를 포함하면 모두 0원으로 변경하고 그 외 단어는 NaN처리했다. 그리고 나머지 데이터는 숫자로 변경하였다

1
2
# 배송비 확인
df["shipping_cost"].value_counts()
1
2
3
4
5
6
7
8
9
10
11
12
무료                        53908
무배                        12669
unknown                   11391
무료배송                       9793
 무료                        4459
                          ...  
쿠폰받으면무료                       1
닌텐도 스위치                       1
2,500, 2만원이상 무료배송             1
와우회원무료, 카드할인20,720            1
 29,800원이상 무료,미만 5,000        1
Name: shipping_cost, Length: 4330, dtype: int64
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def convert_shipping_cost(cost):
    """ 배송비 변환
    Args:
        cost - Str 형식의 배송비
    Returns:
        Nan : unknown이거나, 그 외 Str형 일때
        0 : 무료 배송일때
        int : 그 외 숫자형 일때
    """
    cost = cost.strip()
    if cost.find("무료") > -1:
        return "0"
    elif cost.find("무배") > -1:
        return "0"
    elif cost.replace("원", "").replace(",", "").replace("~", "").replace(".", "").isdigit():
        return cost.replace("원", "").replace(",", "").replace("~", "").replace(".", "")
    else:
        return np.NaN
1
2
df['shipping_cost'] = df['shipping_cost'].map(convert_shipping_cost)
df[['title', 'sales_channel', 'price', 'product_price', 'shipping_cost']].head(2)
 titlesales_channelpriceproduct_priceshipping_cost
0[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)CJ21,600원/무료21600.00
1[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A)지마켓/옥션606,970/무료606970.00

9. 배송비 아웃라이어 확인 및 NaN 처리

배송비 정합성 체크 후 잘못 추출된 배송비가 있을 수 있었다. 배송비가 상위 0.002 이상 (약 2만원)은 NaN 처리를 해주었다.

1
2
# 배송비 아웃라이어 확인
df["shipping_cost"] = df["shipping_cost"].fillna(-1).astype(int).replace({-1: None})
1
2
3
4
5
6
7
# (상위 0.002 제외)
cut = df["shipping_cost"].quantile(0.998)
print(f"기준 가격 {cut}")
temp = df[df["shipping_cost"] > cut]
temp.sort_values("shipping_cost", ascending=False)[["shipping_cost", "price"]]
top_index = temp.index
df.loc[top_index, "shipping_cost"] = np.nan
1
기준 가격 20000.0

10. 키워드 추출

특가 데이터 분석에 용이하게 하기 위해 Kiwi 패키지를 사용하여 제목에서 판매 채널과 가격 정보, 특수 문자를 제외하여 제목을 정제하였으며, 정제된 제목에서 불용어를 제외한 명사형 키워드를 추출하였다. 불용어의 기준은 의미를 모르거나, 자주 등장된 단어 중 필요 없다고 판단된 단어이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def clean_title(title):
    """ 제목 정체
    Args:
        title - Str 형식의 특가 게시물 제목
    Returns:
        title - Str 형식의 판매 채널, 제품 가격 정보, 특수 문자가 제외 된 제목
    """
    # 제목에서 판매 채널 제외
    title = re.sub(r'^\[([^\]]+)\]|\(([^\)]+)\)s*', '', title)
    # 제목에서 가격 정보 제외
    title = re.sub(r'\s*\[([^\]]+)\]|\(([^\)]+)\)$', '', title)
    
    return title
1
2
3
df['title'] = df['title'].astype(str)
df['real_title'] = df['title'].apply(clean_title)
df[['title', 'real_title']].head(2)
 titlereal_title
0[cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료)아이더 반팔 기능티 2장
1[G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97…PS5 디스크 에디션 갓오워 라그나로크 에디션
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def noun_extractor(title):
    """ 명사 추출 및 불용어 처리 함수
    Args:
        title - Str 형식의 판매 채널, 제품 가격 정보, 특수 문자가 제외 된 제목
    Returns:
        results - List : title에서 지정된 불용어를 제외한 명사만 추출된 List
    """
    results = []
    try:
        result = kiwi.analyze(title)
    except:
        return results
    for token, pos, _, _ in result[0][0]:
        if len(token) != 1 and pos.startswith('N') and token not in stopwords:
                results.append(token)
    return results
1
2
3
4
# 불용어
stopwords = ["할인", "쿠폰", "상품", "무료", "스마일", "적용", "카드", "삼성", "세트", "클럽",
            "프로", "증정", "블랙", "인치", "스클", "박스", "에어", "세대", "무선", "랜드", "머니",
            "가능", "캡슐", "샤오미", "결제", "포인트", "구매", "추가", "최대", "배송", "프리미엄"]
1
2
3
tqdm.pandas()
kiwi = Kiwi()
df["keywords"] = df["real_title"].progress_apply(noun_extractor)
1
100%|█████████████████████████████████████████████████████████████████████████| 117980/117980 [00:23<00:00, 4979.96it/s]

11. 인기/핫 게시물과 일반 게시물 라벨링

특가 데이터 분석을 인기/핫 게시물을 중점으로 할 것이므로, 인기/핫 게시물과 일반 게시물을 구별해주는 컬럼을 생성해주었다.

1
2
3
df.loc[df['pop'] == True, 'post_type'] = 'popular/hot'
df.loc[df['hot'] == True, 'post_type'] = 'popular/hot'
df['post_type'].fillna('general', inplace=True)

12. 데이터 저장

전처리한 데이터를 csv 파일로 저장하여, 추후 특가 데이터 분석시 해당 전처리를 진행하지 않아도 되게 하였다. 또한 저장되는 파일명은 현재 시간을 자동으로 지정하여 실수로 다른 파일을 덮어쓰여 저장하지 않게 하였다.

1
2
3
# 데이터 csv 저장
now = str(datetime.now())
df.to_csv(f"./datas/{now}_preprocessing.csv", index=False)
This post is licensed under CC BY 4.0 by the author.