특가 정보에 관심이 많은 사람으로써 특가 데이터 분석을 위해 뽐뿌의 특가 게시판을 크롤링 하여 특가 데이터를 확보하였고, 그 데이터를 전처리하였다. 특가 데이터 분석은 1)데이터 확보(크롤링) 2)데이터 전처리 3)특가 데이터 분석 4)카테고리 예측 모델링순으로 진행된다.
1. 개요
개요
소제목
데이터 분석을 시작하기 전에, 정확한 데이터 분석을 위해 전처리 과정이 필요하다. 데이터 분석가 업무의 80%는 데이터 전처리라는 우스갯소리를 할 정도로 굉장히 많은 시간이 들어가고 많은 고민을 하는것이 데이터 전처리 과정이다. 이번 뽐뿌 특가 데이터 분석에도 데이터 전처리는 빠질수 없는 과정으로 분석을 진행하기 용이하기 데이터 전처리를 진행했다.
데이터 전처리 과정
데이터 전처리는 아래의 3개 과정을 통해 진행되었다.
- 특성 추출: 게시물 제목에서 판매채널, 제품 가격, 배송비 정보를 추출했다.
- 데이터 정제: 추출한 특성에서 결측치, 이상치, 정합성 확인, 통합 등을 처리하여 데이터의 일관성과 정확성을 높였다
- 데이터 변환: 문자열을 숫자로 변환하는 등의 필요한 형태로 데이터를 변환했다.
결론
데이터 전처리를 통해 특가 게시물의 핵심 정보인 판매 채널, 제품 금액, 배송비, 그리고 키워드를 추출하였다. 이러한 과정에서 판매 채널의 통합, 금액 정보의 정제 및 키워드의 최적화 작업으로 데이터 품질을 향상시켰다. 이렇게 향상된 데이터는 특가를 찾는 사용자들의 관심도와 반응을 파악하는 데 큰 도움을 제공할 것이다. 따라서, 전처리된 데이터는 특가 정보의 특성과 트렌드를 더욱 명확하게 보여주며, 사용자들이 더 현명한 소비 결정을 내릴 수 있도록 도와줄 것이다.
데이터 전처리 코드
Package and Data load
데이터 전처리 전에 패키지를 임포트하고 데이터를 로드하여 데이터를 확인한다. 또한, 컬럼의 정의는 아래와 같다.
import pandas as pd
import numpy as np
import re
from tqdm import tqdm
from kiwipiepy import Kiwi
from datetime import datetime
df = pd.read_csv('./datas/2023-06-30 22:27:20.666568_117980개.csv')
df.head(2)
item_no | writer | title | end | comment | date | recommend | opposite | view | category | URL | pop | hot | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 470673 | Ko**** | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | True | 8 | 23.06.29 20:39:22 | 1 | 1 | 7125 | [의류/잡화] | https://www.ppomppu.co.kr/zboard/view.php?id=p... | False | False |
1 | 470672 | 아**** | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97... | True | 15 | 23.06.29 20:03:40 | 0 | 0 | 9811 | [가전/가구] | https://www.ppomppu.co.kr/zboard/view.php?id=p... | False | False |
Column | 설명 |
---|---|
item_no | 게시물 번호 |
Author | 작성자 |
Title | 게시물 제목 |
end | 특가 종료 여부 |
Comments | 댓글 수 |
Date | 게시 날짜 |
recommend | 추천수 |
opposite | 반대수 |
view | 조회수 |
Category | 특가 제품이 속한 카테고리 |
URL | URL |
pop | 인기 게시물 여부 |
hot | 핫 게시물 여부 |
데이터 요약 정보
데이터 전처리의 시작으로 크롤링된 데이터의 요약자료를 보았다. 눈에 뜨는것은 댓글이 1401개 있는 게시물인데, 확인해보니 P11 가성비 태블릿이 역대급 특가였으나, 실제로는 가격 오류로 인한것이였으며, 주문 제품은 모두 취소처리된 게시물이다.
df.describe()
item_no | comment | recommend | opposite | view | |
---|---|---|---|---|---|
count | 117980.000000 | 117980.000000 | 117980.000000 | 117980.000000 | 117980.000000 |
mean | 387483.181395 | 30.878047 | 6.636947 | 0.207391 | 14840.913062 |
std | 48189.476587 | 32.625210 | 13.671075 | 1.476107 | 10264.550307 |
min | 305204.000000 | 0.000000 | 0.000000 | 0.000000 | 731.000000 |
25% | 345619.750000 | 11.000000 | 0.000000 | 0.000000 | 7676.000000 |
50% | 387378.500000 | 21.000000 | 2.000000 | 0.000000 | 12169.000000 |
75% | 428465.250000 | 39.000000 | 7.000000 | 0.000000 | 19035.000000 |
max | 470673.000000 | 1401.000000 | 582.000000 | 141.000000 | 427882.000000 |
df.info()
<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
제목에서 판매 채널, 가격 정보 가져오기
뽐뿌게시판은 제목 맨앞에 판매 채널, 맨 뒤에 가격을 적는것을 규칙으로 하고 있으며, 이번에는 해당 규칙을 활용해 판매 채널과 가격을 제목에서 추출했다. 다만, 게시물 작성 규칙을 지키 않아도 게시글은 작성이 되므로 예외 처리된 데이터도 있을것으로 판단되며, 예외 처리되는 데이터를 줄여야 한다. 전체 특가 데이터는 117,980개로 Null값은 없는것으로 보인다. 또한 int형태와 object 형태로만 되어있어서 데이터를 정리할 필요가 있어보인다.
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)
title | sales_channel | price | |
---|---|---|---|
0 | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | cj온스타일 | 21,600원/무료 |
1 | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97... | G마켓 | 606,970/무료 |
제폼 / 배송비 가격 정보에서 제품 가격과 배송비 정보를 분리
price
컬럼을 보면 제품 가격과 배송비가 같이 적혀있으므로, 이를 다시 분리해준다.
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)
title | sales_channel | price | product_price | shipping_cost | |
---|---|---|---|---|---|
0 | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | cj온스타일 | 21,600원/무료 | 21,600원 | 무료 |
1 | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97... | G마켓 | 606,970/무료 | 606,970 | 무료 |
판매 채널 통합
판매 채널의 갯수는 5,992개이지만 사람이 직접 적는것으로 같은 판매 채널이여도 약어로 적거나 별칭 등 다르게 적을 수 있어서 동일한 채널이라면 하나의 판매채널로 통합한다. 정리하여 5,992개에서 3,751개로 38% 가량 통합하였다.
df["sales_channel"].value_counts()
G마켓 14920
11번가 12163
옥션 11152
위메프 9173
티몬 8857
...
롯데온앱 1
쎄제이 1
파파존스 1
옥션스마일클럽 1
NS쇼핑몰 1
Name: sales_channel, Length: 5992, dtype: int64
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()
지마켓/옥션 35548
11번가 14834
위메프 9247
티몬 9009
네이버 6828
...
110만원대 /무료 1
올렛츠 1
Folderstyle 1
모요/스마텔 1
리브메이트앱 1
Name: sales_channel, Length: 3751, dtype: int64
제품 가격의 자료형을 Str에서 Float형으로 변환
가격 데이터 분석에 용이하기 위해 unknown을 넘파이를 이용하여 NaN으로 변환하고, 그 외 자료는 원
글씨를 제외하고 숫자만 남긴다.
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
df['product_price'] = df['product_price'].map(convert_price_to_int)
df[['title', 'sales_channel', 'price', 'product_price', 'shipping_cost']].head()
title | sales_channel | price | product_price | shipping_cost | |
---|---|---|---|---|---|
0 | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | CJ | 21,600원/무료 | 21,600.0 | 무료 |
1 | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) | 지마켓/옥션 | 606,970/무료 | 606,970.0 | 무료 |
2 | [네이버] 국내산 1등급 소고기 등심 200G (9,900원/4000원) | 네이버 | 9,900원/4000원 | 9,900.0 | 4000원 |
3 | [NS몰] 데이즈온 오한진 초임계 알티지 오메가3 비타플러스 3개월 | NS홈쇼핑 | 9,500원/무료 | 9,500.0 | 무료 |
4 | [옥션] 리큐 진한겔 꿉꿉한냄새 싹 2.1L X 6 [20,930/무료배송] | 지마켓/옥션 | 20,930/무료배송 | 20,930.0 | 무료배송 |
제품 가격 아웃라이어 확인 및 NaN 처리
특가 게시물 등록시 규칙을 지키지 않거나 가격을 여러번 적어 잘못 추출된 가격을 삭제하기 위해 상위 0.0014를 nan 값 처리 했다.
# 상위 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"]]
기준 가격 6050509.500005719
product_price | price | |
---|---|---|
108936 | 7.495909e+17 | 749,590,887,040,974,160/무료 |
101994 | 9.600097e+14 | 96,000원,96,500원,97,000원/무료,무료,5장이상 구매시 무료 |
89044 | 3.570020e+14 | 35700,19800,18990/2500,3000 |
63422 | 3.083063e+14 | 30,830원,62,770원,33,620원/무료 |
76481 | 2.590028e+14 | 25900,27900,30900/무료배송 |
... | ... | ... |
100576 | 9.701960e+06 | 970,1,960/2500 |
44753 | 8.891700e+06 | 889,1700/무료, 카드할인 791,360원, 자급제, 로켓배송 |
80368 | 7.690000e+06 | 769,000,0 |
34872 | 7.324760e+06 | 7324,760/무료 |
96789 | 6.510000e+06 | 651,0000/배송 |
top_index = temp.index
df.loc[top_index, "product_price"] = np.nan
배송비 정합성 확인 및 NaN 처리
배송비에 대한 단어들은 "무료", "무배" 등 여러 가지로 작성되어 있어서 무료배송을 뜻하는 단어를 포함하면 모두 0원으로 변경하고 그 외 단어는 NaN처리했다. 그리고 나머지 데이터는 숫자로 변경하였다
# 배송비 확인
df["shipping_cost"].value_counts()
무료 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
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
df['shipping_cost'] = df['shipping_cost'].map(convert_shipping_cost)
df[['title', 'sales_channel', 'price', 'product_price', 'shipping_cost']].head(2)
title | sales_channel | price | product_price | shipping_cost | |
---|---|---|---|---|---|
0 | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | CJ | 21,600원/무료 | 21600.0 | 0 |
1 | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) | 지마켓/옥션 | 606,970/무료 | 606970.0 | 0 |
배송비 아웃라이어 확인 및 NaN 처리
배송비 정합성 체크 후 잘못 추출된 배송비가 있을 수 있었다. 배송비가 상위 0.002 이상 (약 2만원)은 NaN 처리를 해주었다.
# 배송비 아웃라이어 확인
df["shipping_cost"] = df["shipping_cost"].fillna(-1).astype(int).replace({-1: None})
# (상위 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
기준 가격 20000.0
키워드 추출
특가 데이터 분석에 용이하게 하기 위해 Kiwi 패키지를 사용하여 제목에서 판매 채널과 가격 정보, 특수 문자를 제외하여 제목을 정제하였으며, 정제된 제목에서 불용어를 제외한 명사형 키워드를 추출하였다.
불용어의 기준은 의미를 모르거나, 자주 등장된 단어 중 필요 없다고 판단된 단어이다.
def clean_title(title):
""" 제목 정체
Args:
title - Str 형식의 특가 게시물 제목
Returns:
title - Str 형식의 판매 채널, 제품 가격 정보, 특수 문자가 제외 된 제목
"""
# 제목에서 판매 채널 제외
title = re.sub(r'^\[([^\]]+)\]|\(([^\)]+)\)s*', '', title)
# 제목에서 가격 정보 제외
title = re.sub(r'\s*\[([^\]]+)\]|\(([^\)]+)\)$', '', title)
return title
df['title'] = df['title'].astype(str)
df['real_title'] = df['title'].apply(clean_title)
df[['title', 'real_title']].head(2)
title | real_title | |
---|---|---|
0 | [cj온스타일] 아이더 반팔 기능티 2장 (21,600원/무료) | 아이더 반팔 기능티 2장 |
1 | [G마켓] PS5 디스크 에디션 갓오워 라그나로크 에디션(1218A) (606,97... | PS5 디스크 에디션 갓오워 라그나로크 에디션 |
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
# 불용어
stopwords = ["할인", "쿠폰", "상품", "무료", "스마일", "적용", "카드", "삼성", "세트", "클럽",
"프로", "증정", "블랙", "인치", "스클", "박스", "에어", "세대", "무선", "랜드", "머니",
"가능", "캡슐", "샤오미", "결제", "포인트", "구매", "추가", "최대", "배송", "프리미엄"]
tqdm.pandas()
kiwi = Kiwi()
df["keywords"] = df["real_title"].progress_apply(noun_extractor)
100%|█████████████████████████████████████████████████████████████████████████| 117980/117980 [00:23<00:00, 4979.96it/s]
인기 / 핫 게시물과 일반 게시물 라벨링
특가 데이터 분석을 인기/핫 게시물을 중점으로 할 것이므로, 인기/핫 게시물과 일반 게시물을 구별해주는 컬럼을 생성해주었다.
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)
데이터 저장
전처리한 데이터를 csv 파일로 저장하여, 추후 특가 데이터 분석시 해당 전처리를 진행하지 않아도 되게 하였다. 또한 저장되는 파일명은 현재 시간을 자동으로 지정하여 실수로 다른 파일을 덮어쓰여 저장하지 않게 하였다.
# 데이터 csv 저장
now = str(datetime.now())
df.to_csv(f"./datas/{now}_preprocessing.csv", index=False)
'Data Science > Data Engineering' 카테고리의 다른 글
업비트 크롤링 (Crawling) (1) | 2024.12.03 |
---|---|
아나콘다 가상환경 주피터랩에서 쉽게 쓰기 (1) | 2024.11.30 |
뽐뿌 특가 게시판 크롤링하기 (0) | 2024.11.23 |