08_ML(Machine_Learning)

15_로지스틱 감성분류(자연어 처리) - 맛집 리뷰

chuuvelop 2025. 4. 8. 17:38
728x90
감성분석
  • 분류 모델의 가장 대표적인 활용 방법 중 하나
  • 텍스트 데이터를 긍정 또는 부정으로 나누어 분류하는 것

 

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from selenium import webdriver
from bs4 import BeautifulSoup
import re # regular expression 텍스트 데이터의 패턴을 파악할 때 사용
import time
from seleniuhttp://m.webdriver.common.by import By
from seleniuhttp://m.webdriver.common.keys import Keys
import requests
from sklearn.feature_extraction.text import TfidfVectorizer # 텍스트데이터 임베딩(숫자로 변환)에 쓰임
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix

 

 

01. 맛집 상세페이지 url정보 크롤링
# 크롤링할 주소
url = "https://map.kakao.com/"

 

driver = webdriver.Chrome()

# 카카오지도 접속
driver.get(url)
driver.implicitly_wait(3)

# 검색창에 검색어 입력
searchbox = driver.find_element(By.CSS_SELECTOR, "input.query")
searchbox.send_keys("신촌 맛집")

# 엔터 눌러서 결과 받아오기
searchbox.send_keys(Keys.ENTER)

# 검색 결과를 가져올 시간 대기(묵시적 대기는 페이지가 로드 되는것을 기다리는 것이므로 쓸 수 없다)
time.sleep(1)

# html정보 파싱
soup = BeautifulSoup(driver.page_source, "lxml")
moreviews = soup.select("a.moreview")

# 크롤링할 상세페이지 url 리스트 생성
page_urls = []

for more in moreviews:
    page = more.get("href")
    page_urls.append(page)

 

moreviews[0].get("href")
'https://place.map.kakao.com/27144649'

 

page_urls
['https://place.map.kakao.com/27144649',
 'https://place.map.kakao.com/1011256721',
 'https://place.map.kakao.com/12441959',
 'https://place.map.kakao.com/2135513146',
 'https://place.map.kakao.com/17505297',
 'https://place.map.kakao.com/10621045',
 'https://place.map.kakao.com/15937430',
 'https://place.map.kakao.com/26956113',
 'https://place.map.kakao.com/10386326',
 'https://place.map.kakao.com/20225173',
 'https://place.map.kakao.com/1165755646',
 'https://place.map.kakao.com/18287294',
 'https://place.map.kakao.com/69756475',
 'https://place.map.kakao.com/1036192129',
 'https://place.map.kakao.com/8122805']

 

 

 

02. 상세페이지에서 리뷰 크롤링
# 네트워크 페이지에서 리뷰URL 찾기
res = requests.get("https://place.map.kakao.com/commentlist/v/27144649")

 

data = res.json()

 

data["comment"]["list"][0]
{'commentid': '12417219',
 'contents': '가끔 가볼만한 식당(2.9/5.0)\n\n여유가 없는 학생들에게 고마운 식당 \n훌륭한 가성비, 보통보다 떨어지는 맛. \n정신없는 가게, 밥 먹었으면 바로 나가야하는 조급함. \n대기해야 먹을 수 있는 점. \n하지만 훌륭한 가성비. ',
 'point': 3,
 'username': 'Square',
 'profile': '',
 'profileStatus': 'S',
 'photoCnt': 0,
 'likeCnt': 0,
 'kakaoMapUserId': '1398887468',
 'photoList': [],
 'ownerReply': {},
 'strengths': [{'id': 1, 'name': '가성비'}],
 'userCommentCount': 5,
 'userCommentAverageScore': 3.2,
 'myStorePick': False,
 'level': {'nowLevel': 3, 'badge': '01'},
 'date': '2025.04.01.',
 'isMy': False,
 'isBlock': False,
 'isEditable': False,
 'isMyLike': False}

 

 

data["comment"]["list"][0]["point"]
3

 

 

data["comment"]["list"][0]["contents"]
'가끔 가볼만한 식당(2.9/5.0)\n\n여유가 없는 학생들에게 고마운 식당 \n훌륭한 가성비, 보통보다 떨어지는 맛. \n정신없는 가게, 밥 먹었으면 바로 나가야하는 조급함. \n대기해야 먹을 수 있는 점. \n하지만 훌륭한 가성비. '

 

 

len(data["comment"]["list"])
5

 

data["comment"]["list"][-1]
{'commentid': '12317652',
 'contents': '까먹고 사진안찍음\n진짜 이런 가성비집은 없음\n3조각 8천원 시키면 왠만한 돼지들 배터짐\n고기도 두껍지만 튀김옷(밀가루)도 굉장히 두꺼워서 밀가루냄새?도 많이남\n\n물도 사먹어야함\n',
 'point': 3,
 'username': '5점잘안준다',
 'profile': 'http://t1.daumcdn.net/local/kakaomapPhoto/profile/da6985f3aa175d068cae9868a40ef433747f1964?original',
 'profileStatus': 'S',
 'photoCnt': 0,
 'likeCnt': 1,
 'kakaoMapUserId': '2584073258',
 'photoList': [],
 'ownerReply': {},
 'strengths': [{'id': 5, 'name': '맛'}, {'id': 1, 'name': '가성비'}],
 'userCommentCount': 34,
 'userCommentAverageScore': 3.0,
 'myStorePick': False,
 'level': {'nowLevel': 21, 'badge': '02'},
 'date': '2025.03.13.',
 'isMy': False,
 'isBlock': False,
 'isEditable': False,
 'isMyLike': False}

 

 

data["comment"]["list"][-1]["commentid"]
'12317652'

 

 

# 다음 리뷰 url(/뒤에 마지막 리뷰의 아이디)
"https://place.map.kakao.com/commentlist/v/27144649/12317652"
'https://place.map.kakao.com/commentlist/v/27144649/12317652'

 

 

stars = []
reviews = []

# 댓글 정보 url
base_url = "https://place.map.kakao.com/commentlist/v/"

# 위에서 수집한 15개 가게에 대해서 수집
for url in page_urls:
    last_id = ""

    # 10번 더보기 수행
    for _ in range(10):
        res = requests.get(base_url + url[28:] + "/" + last_id)
        data = res.json()

        # 별점, 리뷰 수집
        try:
            for review in data["comment"]["list"]:
                stars.append(review["point"])
                reviews.append(review.get("contents", ""))
        except KeyError:
            break

        # 더보기 시에 사용할 마지막 id
        last_id = review["commentid"]
        time.sleep(1)

 

 

len(reviews)
661

 

len(stars)
661

 

 

df = pd.DataFrame({"score" : stars, "review" : reviews})
df.head()

 

 

df.to_csv("./data/kakaomap.csv", index = False)
# 4점 이상 리뷰는 긍정 리뷰, 3점 이하는 부정 리뷰로 평가
df["y"] = df["score"].map(lambda x: 1 if x > 3 else 0)

 

df.head()

 

# 레이블값 비율
df["y"].value_counts()
y
1    471
0    190
Name: count, dtype: int64

 

 

 

03. 텍스트 전처리

 

한글 추출

def text_cleaning(text:str):
    '''
    텍스트 정제 함수
    한글 이외의 문자는 전부 제거
    '''
    # 한글 이외의 문자들 추출
    han = re.compile("[^ ㄱ-ㅣ가-힣]+") #^(Not) 빈칸: 빈칸이 아니면서 
    # 한글 이외의 문자들 제거
    text = han.sub(" ", text)

    return text

 

df["review"].map(text_cleaning)
0      가끔 가볼만한 식당 여유가 없는 학생들에게 고마운 식당  훌륭한 가성비  보통보다 ...
1      아니 가격과 크기를 보고 말을 하셈 현금만이니까 이 가격인거고  카드랑 계좌이체 안...
2                                                압도적 가성비
3      현금영수증 가능하고 매장 깔끔하기까지  저가격에 현금 밖에 안된다고 탈세 타령 광광...
4      까먹고 사진안찍음 진짜 이런 가성비집은 없음 조각  천원 시키면 왠만한 돼지들 배터...
                             ...                        
656                                                터줏대감 
657                                       앤틱한 데이트코스 밀크티 
658         분위기 조용해서 맘에 듦  소파가 푹신하고  명이서도 넓은 자리에 앉을 수 있다
659                                       밀크티 맛집에 외국 분위기
660                                                  ...
Name: review, Length: 661, dtype: object

 

df["ko_text"] = df["review"].map(text_cleaning)

 

df.head()

 

df.shape
(661, 4)

 

df[df["ko_text"].str.len() == 0]

 

 

df = df[df["ko_text"].str.len() > 0]
df

 

 

df.shape
(533, 4)

 

 

텍스트 임베딩

  • 텍스트 데이터를 연산이 가능한 피처로 변환

 

TF-IDF(Term Frequency-Inverse Document Frequency)

  • 단어의 빈도와 역 문서 빈도를 사용하여 각 단어들의 중요도를 가중치로 주는 방법
  • TF(Term Frequency) : 1개의 문서 내에서 특정 단어의 등장 빈도
    • 문서 d에서 단어 t가 등장한 횟수 / 문서 d에 등장한 모든 단어의 수
  • DF(Document Frequency) : 특정 단어가 등장한 문서의 수
    • 단어 t를 포함하는 문서의 수 / 총 문서의 개수
  • IDF : DF에 반비례하는 수
    • log(총 문서의 개수 / 단어 t를 포함하는 문서의 수)
  • 다른 문서들에서는 많이 등장하지 않았지만 현재 문서에서는 많이 등장하는 단어를 의미
    • 해당 단어가 현재 문서에서 얼마나 중요한지를 계산하는 방법

 

x = df["ko_text"]
y = df["y"]

 

x_train, x_test, y_train, y_test = train_test_split(x, y, test_size = 0.3, stratify = y, random_state = 26)

 

x_train.shape, x_test.shape
((373,), (160,))

 

tfidf = TfidfVectorizer()
tf_train = tfidf.fit_transform(x_train)
tf_test = tfidf.transform(x_test)

 

 

tf_train.shape
(373, 3530)

 

 

 

04. 모델 훈련
logi = LogisticRegression()
logi.fit(tf_train, y_train)

 

pred = logi.predict(tf_test)
proba = logi.predict_proba(tf_test)[:, 1]

 

print(accuracy_score(y_test, pred))
print(f1_score(y_test, pred))
0.7
0.8235294117647058
 
logi.score(tf_test, y_test)
0.7
 
 
comat = confusion_matrix(y_test, pred)
print(comat)
[[  0  48]
 [  0 112]]

 

 

pd.DataFrame({"text" : x_test, "pred" : pred})

 

 

# over sampling(오버 샘플링)(없는 데이터를 만들어야하므로 더 어려움): 긍정 데이터를 늘리는 방향
# under sampling(언더 샘플링): 긍정 데이터를 줄이는 방향

 

 

 

05. 모델 최적화
df[df["y"] == 0].shape
(160, 4)

 

# 373중 160개만 추출하여 부정 데이터와 개수를 맞춤
df[df["y"] == 1].shape
(373, 4)

 

 

# 1:1 비율로 랜덤 샘플링 수행
pos_idx = df[df["y"] == 1].sample(160, random_state = 26).index.tolist()
neg_idx = df[df["y"] == 0].index.tolist()
pos_idx
[87,
 99,
 111,
 ...

 

random_idx = pos_idx + neg_idx

 

len(random_idx)
320

 

sample_x = x[random_idx]
sample_y = df["y"][random_idx]

 

len(sample_x), len(sample_y)
(320, 320)

 

x_train, x_test, y_train, y_test = train_test_split(sample_x, sample_y, stratify = sample_y, test_size = 0.3, random_state = 26)
tfidf = TfidfVectorizer()
tf_train = tfidf.fit_transform(x_train)
tf_test = tfidf.transform(x_test)

 

tf_train.shape
(224, 2428)

 

logi = LogisticRegression()
logi.fit(tf_train, y_train)

 

pred = logi.predict(tf_test)

 

print(accuracy_score(y_test, pred))
print(f1_score(y_test, pred))
0.7083333333333334
0.6888888888888889

 

comat = confusion_matrix(y_test, pred)
comat
array([[37, 11],
       [17, 31]], dtype=int64)

 

 

logi.score(tf_train, y_train)
0.9866071428571429

 

 

pd.DataFrame({"text" : x_test, "pred" : pred, "ans" : y_test})

 

 

# 테스트(괄호 안에 테스트하고자 하는 문장을 넣고 문장을 테스트)
query = tfidf.transform(["존맛탱"])
logi.predict(query)
array([1], dtype=int64)

 

 

06. 키워드 분석
  • 로지스틱 회귀 모델의 피처 영향력으로 가장 높은 영향력을 가지고 있는 단어 찾기
plt.figure()

plt.bar(range(len(logi.coef_.flatten())), logi.coef_.flatten())
plt.show()

 

logi.coef_.flatten().shape
(2428,)

 

# 회귀 모델의 계수를 내림차순으로 정렬
coef_pos_idx = sorted(((value, idx) for idx, value in enumerate(logi.coef_.flatten())), reverse = True)

 

coef_pos_idx[:5]
[(1.1498213957101484, 768),
 (0.7240695955867349, 2138),
 (0.5357308389754818, 749),
 (0.5090855755852185, 1749),
 (0.4884092524219561, 368)]

 

 

# 상위 20개 긍정 형태소
for value, idx in coef_pos_idx[:20]:
    print(tfidf.get_feature_names_out()[idx], value)
맛있어요 1.1498213957101484
최고 0.7240695955867349
맛잇음 0.5357308389754818
인도커리 0.5090855755852185
낙지찜 0.4884092524219561
오징어볶음 0.4540755102830212
너무 0.4263574690940327
조금 0.4257153286792542
좋아요 0.42557432182724303
추천합니당 0.41062406256096373
맛있게먹었음 0.41062406256096373
맛나용 0.41062406256096373
마늘빵원탑 0.41062406256096373
돼지가되 0.41062406256096373
고등어진짜맛있어요 0.41062406256096373
맛있다 0.40658227982469614
중국집 0.3913297589151092
좋고 0.38352184333516803
맛도 0.3770272949171766
가성비 0.3692752990367915

 

 

# 상위 20개 부정 형태소
for value, idx in coef_pos_idx[:-20:-1]:
    print(tfidf.get_feature_names_out()[idx], value)
많이 -0.5684372678758975
노맛 -0.49418812232278087
펀킨파이 -0.4596241999713655
별로 -0.4418236939275283
별로에요 -0.4352351702442645
맛은 -0.42723156197171974
낫밷 -0.39133667078080564
맥주 -0.39133667078080564
무난했어요 -0.39133667078080564
빈약해요 -0.39133667078080564
하나도 -0.35308689717011826
신고 -0.33160324010601855
불친절합니다 -0.32806418464054704
친절 -0.3278657105895523
재구매의사 -0.3248620705445928
카드 -0.31058159228410087
훌륭한 -0.3085116126401031
좋은 -0.3007044873261531
반찬이 -0.29552035931619086
 
 
[ ]:
 
728x90

'08_ML(Machine_Learning)' 카테고리의 다른 글

18_확률적 경사 하강법  (0) 2025.04.09
16_로지스틱 회귀분석_심화  (1) 2025.04.08
14_로지스틱 회귀  (0) 2025.04.07
12_야구선수 연봉_선형회귀  (1) 2025.04.04
11_Ridge, Lasso, Elastic Net  (0) 2025.04.04