본문 바로가기
text/Python

점진적 뉴스 군집화 하기 (incremental news clustering)

by hoonzii 2022. 6. 6.
반응형

요즘 관심 가지던게 하나 있는데 바로 점진적 문서 군집화

 

무슨 소리냐

뉴스의 경우, 계속 써지고 발간되고 사람들한테 제공된다.

지금까지 내가 해온건 어떤 시간대 (가령, 하루단위) 뉴스를 군집화(clustering) 한뒤,

비슷한 주제, 이슈로 묶여있길 바라며 군집을 살펴보는 일이였다. (오늘의 주요 이슈는 무엇인지 군집화된 뉴스를 통해 살펴보기 위해)

문제가 있다.

  1. 뉴스는 계속 만들어지고, 이슈는 계속 변한다.
  2. 특정 데이터를 통해 만든 문서 벡터 공간은 새로운 데이터가 나타나면 유효하지 않다 (새로운 feature가 생긴다는 얘기다. 벡터 공간을 통한 비교를 수행할 수 없다.)

위의 두문제를 해결하면서도 주요 이슈를 확인하기 위한 군집화를 위해

하루치 몰아서 하는게 아니라 특정시간대별로 군집화를 수행한 뒤,

이전에 수행된 군집과 병합을 할 수 있다면 변하는 이슈를 관찰할 수 있지 않을까? 싶었다.

 

하지만 2번 문제에 대해 해결할 수 있어야 한다.

feature가 늘어남에 따라 벡터 공간을 유연하게 늘려나갈 수 있는지에 대해서 찾아봤는데

못찾아서 (알고있다면 댓글로 알려주시면 감사…)

다른곳은 어떻게 하는지 찾아보았다.

https://blog.naver.com/PostView.naver?blogId=naver_search&logNo=222439504418 

 

네이버 뉴스 추천 알고리즘에 대해 (Part2)

이전 편 ‘네이버 뉴스 추천 알고리즘에 대해 (Part1)’ 에서는 네이버 뉴스 추천 서비스에서 활용되고 있...

blog.naver.com

네이버 이 포스트를 보고 힌트를 얻었다.

 

벡터 공간을 유연하게 늘린게 아니라 특정시간대 기사를 군집하고, 다른 여러 요소를 고려할 뿐 벡터공간을 늘려가는게 아니였다.

그래서 벡터 공간을 점진적으로 늘리는건 마음을 비우고,

기준 시간대 (예를 들어 1시간) 벡터 공간을 통해 기사를 군집한뒤,

이전에 만들어진 기사 군집과 비교는 벡터 비교가 아닌 다른 방법을 통해 기사 군집끼리 비교&병합을 하고자 한다.

내가 수행할 로직을 글로 적어보면,

  • 00:00:00~00:59:59 기사 → 전처리 & 임베딩(벡터화) → 군집화 (c1) (이전군집 존재x)

  • 01:00:00~01:59:59 기사 → 전처리 & 임베딩(벡터화) → 군집화(c2) → 이전군집(c1)과 비교&병합,흡수(c’1)

  • 02:00:00~02:59:59 기사 → 전처리 & 임베딩(벡터화) → 군집화(c3) → 이전군집(c’1)과 비교&병합,흡수(c’2)

로직이 실제로 이슈를 잘 뽑아내는지 코드로 돌려보자

( 나는 네이버뉴스 “정치일반” 2022-05-21 기사 url 들을 모아놨었다.)

import pandas as pd
from tqdm.notebook import tqdm
import requests
from bs4 import BeautifulSoup
import random
import time
import pprint

article_df = pd.read_pickle("./naver_news_20220521.pkl") 
print('article num : ',len(article_df))
article_df.head()

기사 수집 & 전처리

1209개 의 기사인데 그중 첫번째 5개중 4개가 똑같은 기사로 보인다.

실제로 같은 기사일 경우 url이 같을테니 중복 검사&삭제를 해주자.

article_df = article_df.drop_duplicates(['url'])
print('article num : ',len(article_df))

같아 보인거지 같진 않았나보다.

url 로 접근해 실제 기사를 가져오자.

save_list = []
for i, row in tqdm(article_df.iterrows(), total=len(article_df)):
    title = row['title']
    news_url = row['url']
    headers = {
                "user-agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36"
            }
    try:
        response = requests.get(news_url, headers = headers)
        soup = BeautifulSoup(response.text, 'html.parser')
        content = soup.find("div",{'class',"newsct_article"}).text
        news_dateTime = soup.find("span",{'class':"media_end_head_info_datestamp_time _ARTICLE_DATE_TIME"}).get("data-date-time")
    except:
        print("error occured")
        continue
    
    news_dict = {
        "title" : title,
        "content" : content,
        "datetime" : news_dateTime,
        "url" : news_url
    }
    save_list.append(news_dict)
        
news_list_df = pd.DataFrame(save_list)
news_list_df.to_pickle("./news_data_20220521.pkl") # 저장도 잊으면 안된다.

article_df = news_list_df.drop_duplicates(['url'])
print('article num : ',len(article_df))

위 스크린샷을 보면 알수있듯이 기사 내용(content) 의 경우 기호나 불용어가 잔뜩 들어가 있는걸 볼 수 있다.

깨끗하게 지워주자.

import re

# 전처리 함수 구성
def clean_text(text):
    text = text.replace("&lt;","<").replace("&gt;",">") # 괄호로 변경
    text = re.sub('(<([^>]+)>)', '', text) # 괄호내 문자와 괄호를 제거
    text = text.replace("&#039","").replace("&quot;","") # 의미 없는 문자 제거
    
    pattern = '([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)' 
    text = re.sub(pattern=pattern, repl='', string=text) #이메일 제거
    
    pattern = '(http|ftp|https)://(?:[-\w.]|(?:%[\da-fA-F]{2}))+'
    text = re.sub(pattern=pattern, repl='', string=text) # url 제거

    pattern = '([ㄱ-ㅎㅏ-ㅣ]+)'  
    text = re.sub(pattern=pattern, repl='', string=text) # 자음, 모음 만 존재시 제거

    pattern = '<[^>]*>'        
    text = re.sub(pattern=pattern, repl='', string=text) # 괄호 <> 와 괄호내 문자제거 

    pattern = r'\([^)]*\)'
    text = re.sub(pattern=pattern, repl='', string=text) # 괄호 () 와 괄호내 문자제거

    pattern = r'\[[^)]*\]'
    text = re.sub(pattern=pattern, repl='', string=text) # 괄호 [] 와 괄호내 문자제거
    
    pattern = '[^\w\s.]'   
    text = re.sub(pattern=pattern, repl='', string=text) #단어, 띄어쓰기, 문자"." 이외의 특수문자 모두 제거
    text = text.strip()
    text = " ".join(text.split())

    return text

전처리 함수로 content 불용어 제거

임베딩 (문서 -> 벡터화)

임베딩의 차례다. 문서를 벡터로 만드는 과정인데, 그러기 위해선 문서를 feature로 바꿔야 한다.

한국어에선 문서나 문장을 보통 음절,어절,형태소,단어 등 으로 바꾸는데,

오늘은 형태소 단위로 바꾼 뒤, 벡터로 변환해본다.

from konlpy.tag import Komoran

tokens_list = []
komoran = Komoran()
for i, row in tqdm(article_df.iterrows(), total = len(article_df)):
    title = row['title']
    title = clean_text(title)
    content = row['content']
    document = title+" "+content
    tokens = [token[0] for token in komoran.pos(document)]
    tokens_list.append(tokens)

article_df['token_list'] = tokens_list

이제 위 dataFrame을 시나리오에 맞춰 시간대별로 나눠준다.

시간대별로 나뉜 기사는 임베딩 → 군집화 과정을 거친다.

먼저 시간대 별로 나누기. pandas dataFrame은 시간대별로 나누기 편하게 되어있다.

s_date = "2022-05-21 00:00:00"
e_date = "2022-05-21 00:59:59"

sub_df = article_df[article_df['datetime'].between(s_date,e_date)]
sub_df.index = range(len(sub_df))
sub_df

위 처럼 00:00:00~ 00:59:59 시간대 기사가 수집되었다고 치자.

이제 기사들의 형태소를 통해 해당 시간대 벡터 공간을 만든다.

이번에는 TF-IDF 알고리즘을 통해 임베딩을 진행한다.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import Normalizer

def build_vector(tokens_list): # tokens_list
    text = [ " ".join(tokens) for tokens in tokens_list]
    tfidf_vectorizer = TfidfVectorizer(analyzer="word", min_df = 1, ngram_range=(1,3))
    tfidf_vectorizer.fit(text)
    vector = tfidf_vectorizer.transform(text).toarray()
    
    normalizer = Normalizer()
    vector = normalizer.fit_transform(vector)
    return vector

sub_df_tokens_list = []
for i, row in sub_df.iterrows():
    token_list = row['token_list']
    sub_df_tokens_list.append(token_list)

sub_df_vector = build_vector(sub_df_tokens_list)
sub_df_vector, len(sub_df_vector)

코드 실행 결과다. 00시간대 기사의 개수는 총 9개로 결과로 반환된 sub_df의 vector 개수 역시 9개를 확인할 수 있다.

각 벡터의 차원은 feature의 수로 나타나는데 (TF-IDF니까) 9956 차원의 벡터로 표현됐다.

하지만 굉장히 sparse 하다. 비슷한 문서는 벡터 역시 비슷할 것이다.

(이 문서내 출현단어가 다른 문서내 출현하지 않으면 다른 문서의 벡터의 feature는 0으로 표시 된다. 9956차원 중 0이 아닌부분은 머 한 1%~10% 되려나? )

 

군집화

각 문서별로 임베딩 벡터가 준비 되었으니 군집화 알고리즘을 통해 군집을 시도 한다.

이전에 블로그에 군집화 알고리즘을 하나 쓴적이 있다. DBSCAN이라고 벡터의 밀집도를 통해 군집하는 형식이다.

자세한건 여기 이 게시글을 참고하면 되고!

 

뉴스 문서 군집화 하기 (document clustering with DBSCAN)

문서 클러스터링 해보려고 한다. 순서는 1. 데이터 모으기 2. 데이터를 분류 가능하게 변환하기 3. 변환된 데이터 분류하기 로 단순화 시킬 수 있다. 사용한 모듈의 경우 다음과 같다. - python 3.7 - r

hoonzi-text.tistory.com

해당 알고리즘으로 기사를 묶은 결과를 살펴보자.

기사 제목을 살펴볼때 사람이 보기에 묶여야 되는 기사들이 보이지만 DBSCAN 알고리즘을 통해서는 묶이지 않은 것을 확인 할 수 있다. 여러 문제가 있을수 있지만(파라미터 설정이 잘못된것이든, 유사도 방식이 틀렸든…)

이번 문제에서는 잘 맞지 않았다. (뭐가 문제 였는지 알려주시면 감사)

그래서 위에서 참고한 네이버의 방식 에서 소개된 hierarchical clustering을 이용했다.

간략히 설명하자면 각 벡터를 하나의 클러스터로 여기고, 가장 가까운 벡터를 하나로 병합하면서 최종적으로 하나의 클러스터로 묶일때까지 묶는 방식이다.

자세한 설명은 여기 서 보도록 하자

 

Hierarchical clustering - Wikipedia

From Wikipedia, the free encyclopedia Jump to navigation Jump to search Statistical method of analysis which seeks to build a hierarchy of clusters "SLINK" redirects here. For the online magazine, see Slink. In data mining and statistics, hierarchical clus

en.wikipedia.org

DBSCAN 과 마찬가지로 sklearn 을 이용했다.

from sklearn.cluster import AgglomerativeClustering
def clustering_hierachy(vector, threahold=0.85):
    model = AgglomerativeClustering(linkage="average",  # vector끼리 bottom-up 방식으로 병합
                                distance_threshold = threahold, # 기준치 미만인 경우 다른 군집으로 판단
                                n_clusters=None, # 클러스터의 개수 지정X
                                affinity="cosine", # cosine similarity 방식으로 벡터 비교
                                compute_full_tree ="auto")
    model.fit_predict(vector) # 해당 벡터가 어디에 속하는지 판단
    return model.labels_ # 클러스터 반환

labels = clustering_hierachy(sub_df_vector)

# 같은 라벨 끼리 묶어 보기
article_dict = {}
for i, label in enumerate(labels):
    if label not in article_dict:
        article_dict[label] = [sub_df['title'][i]]
    else:
        article_dict[label].append(sub_df['title'][i])
        
pprint.pprint(article_dict)

위 DBSCAN 에 비해 (윤석열-바이든 방한), (이재명 유세이슈) 에 대한 주제로 묶여있는게 보인다.

군집화 알고리즘까지 정해졌으니 정리를 해보자.

 

우리는 지금

특정시간대 기사를 모으고, (모았다 치고!)

전처리 & 정제 했고, (불용어를 제거, 형태소단위로 정제)

벡터로 변환했고, (TF-IDF 벡터로 변환)

해당 벡터로 군집화를 했다. (hierarchical clustering)

 

이제 특정 시간대 마다 해당 작업은 수행 된다고 치면!

지금 현재 시간대 군집과 이전 군집과 비교 작업을 한뒤, 비슷한 주제 군집에 대해서는 병합이 이루어 져야 하고,

그렇지 않은 군집은 단순 추가 작업이 필요하다.

 

군집 병합

해당 문제는 주제가 비슷하다 판단은 어떻게 해야 할까? 에 답을 해야 한다.

이전까지 고민하던 부분이 나는 문서 벡터에 투영한뒤 거리를 비교하고 싶었고, 그것때문에 벡터 공간의 점진적 증가를 찾아봤었던 것이다.

단순하게 생각하기로 했다. 이전 시간대 기사의 벡터를 위해 분해된 형태소들이 존재할 것이고,

지금 시간대 역시 마찬가지다. 그렇다면 단순히 겹치는 형태소가 많다면 비슷하다고 하면 어떨까?

 

여기 자카드 유사도( jaccard similarity ) 라는 유사도 측정 기법이 있다. 이전에 블로그에도 쓴적이 있지만

위키에서 가져온 설명을 보자면

집합간의 유사도를 비교하는 방법으로 교집합/합집합 으로 나눠 해당 값을 유사도로 보는 기법이다.

 

각 군집이 가지고 있는 형태소를 집합으로 보고,

군집과 군집간의 자카드 유사도를 본 뒤, 일정 기준치를 넘으면 비슷하다고 여겨도 되지 않을까?

00:00:00 ~ 00:59:59 시간대를 article_dict

01:00:00 ~ 01:59:59 시간대를 article_dict_next 라는 변수에 저장해 병합을 수행해 보자.

 

article_dict 예시

형태는 이렇다.

이어서 article_dict_next

군집과 군집간 비교시 M(이전 군집 개수) * N(현재 군집 개수) 만큼 비교를 하게 된다.

시간 복잡도는 O(N^2) 라고 할수 있겠다.

 

이전 군집과 비교 (군집내 tokens jaccard similarity)

jaccard_similarity = |X∩Y| / |X∪Y| = 교집합 / 합집합

merge_dict = article_dict.copy() # 이전 군집
current_dict = article_dict_next.copy() # 현재 군집

merge_tuple = [] # 두 군집간의 병합할 군집의 숫자를 튜플로 저장
already_check = set() # 병합하기로 결정된 군집의 숫자는 다른 군집과 비교 X 하기 위해

for cluster_i_num in merge_dict:
    cluster_i = merge_dict[cluster_i_num] # 이전 군집 i
    
    cluster_i_tokens = set() # 군집 i의 token 저장
    for article in cluster_i:
        token_list = article['token_list']
        cluster_i_tokens.update(token_list)
    
    for cluster_j_num in current_dict:
        if cluster_j_num in already_check: # 이미 병합하기로 결정난 군집은 건너뛰기
            continue
        
        cluster_j = current_dict[cluster_j_num] # 현재 군집 j
        
        cluster_j_tokens = set() # 군집 j의 token 저장
        for article in cluster_j:
            token_list = article['token_list']
            cluster_j_tokens.update(token_list)
            
        intersection = set(cluster_i_tokens).intersection(cluster_j_tokens)
        union = set(cluster_i_tokens).union(cluster_j_tokens)
        if len(union) == 0:
            jaccard_similarity = 0.0
        else:
            jaccard_similarity = len(intersection) / len(union)
        if jaccard_similarity >= 0.35: # 두 token 집합 간의 유사도가 0.35 이상 일때
            merge_tuple.append((cluster_i_num, cluster_j_num)) # 병합을 위해 각 군집의 숫자를 저장
            already_check.add(cluster_j_num) # 병합 하기로 결정했기에 해당 군집은 비교에서 제외

자카드 유사도 기준은 0.35로 잡았다.(테스트로 여러번 돌려본 결과 저 수치 정도에서 잘 묶이는 듯 하여…)

그렇다면 결과는 어떨까 살펴보자.

위 코드를 보면 merge_tuple에 이전 클러스터와 현재 클러스터 간의 묶여야하는 클러스터의 숫자가 저장된다.

이전 클러스터 모음(article_dict) 중의 0번 클러스터와

현재 클러스터 모음(article_dict_next) 중의 1번 클러스터는 이렇다.

병합 해야 할 클러스터들이 잘 검출 된 것을 확인 할 수 있다.

병합 정보 merge_tuple를 통해 병합 & 기존 클러스터 집합에 추가해준다.

함수로 만들어 준다.

def merge(merge_dict, current_dict):
    merge_tuple = [] # 두 군집간의 병합할 군집의 숫자를 튜플로 저장
    already_check = set() # 병합하기로 결정된 군집의 숫자는 다른 군집과 비교 X 하기 위해
    for cluster_i_num in merge_dict:
        cluster_i = merge_dict[cluster_i_num] # 이전 군집 i

        cluster_i_tokens = set() # 군집 i의 token 저장
        for article in cluster_i:
            token_list = article['token_list']
            cluster_i_tokens.update(token_list)

        for cluster_j_num in current_dict:
            if cluster_j_num in already_check: # 이미 병합하기로 결정난 군집은 건너뛰기
                continue

            cluster_j = current_dict[cluster_j_num] # 현재 군집 j

            cluster_j_tokens = set() # 군집 j의 token 저장
            for article in cluster_j:
                token_list = article['token_list']
                cluster_j_tokens.update(token_list)

            intersection = set(cluster_i_tokens).intersection(cluster_j_tokens)
            union = set(cluster_i_tokens).union(cluster_j_tokens)
            if len(union) == 0:
                jaccard_similarity = 0.0
            else:
                jaccard_similarity = len(intersection) / len(union)
            if jaccard_similarity >= 0.35: # 두 token 집합 간의 유사도가 0.35 이상 일때
                merge_tuple.append((cluster_i_num, cluster_j_num)) # 병합을 위해 각 군집의 숫자를 저장
                already_check.add(cluster_j_num) # 병합 하기로 결정했기에 해당 군집은 비교에서 제외
                
    # 병합
    for merge_info in merge_tuple:
        cluster_i_num = merge_info[0]
        cluster_j_num = merge_info[1]

        merge_dict[cluster_i_num].extend(current_dict[cluster_j_num])
        del current_dict[cluster_j_num]

    # 병합되지 않은 군집은 추가
    for cluster_j_num in current_dict:
        keys = merge_dict.keys()
        keys = sorted(keys) 

        new_cluster_num = keys[-1]+1
        merge_dict[new_cluster_num] = current_dict[cluster_j_num]

    return merge_dict

 

자 다시 정리해보자면

우리는 지금

특정시간대 기사를 모으고, (모았다 치고!)

전처리 & 정제 했고, (불용어를 제거, 형태소단위로 정제)

벡터로 변환했고, (TF-IDF 벡터로 변환)

해당 벡터로 군집화를 했다. (hierarchical clustering)

그러곤 이전 군집과 병합&추가 해준다. (jaccard similarity)

 

시간대 별로 로직 수행

나는 2022-05-21 날 정치일반 뉴스 하루치를 모아놨고,

이걸 우리 문제에 맞춰 구현하기 위해서는 시간대별로 나누어 로직을 수행해야 한다. 간이로 구현해보자.

우선 시간대로 나눈다.

시간대에 맞춰 start_date, end_date를 구할수 있다면 해당 뉴스를 dataframe에서 꺼내 위 과정을 반복해준다.

# 시작일,종료일 설정
start = "2022-05-21 00:00:00"
last = "2022-05-21 23:00:00"

# 시작일, 종료일 datetime 으로 변환
start_date = datetime.strptime(start, "%Y-%m-%d %H:%M:%S")
last_date = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")

merge_dict = {}
current_dict = {}

# 종료일 까지 반복
while start_date <= last_date:
    
    end_date = start_date + timedelta(hours=1) + timedelta(seconds=-1)
    
    s_date = start_date.strftime("%Y-%m-%d %H:%M:%S")
    e_date = end_date.strftime("%Y-%m-%d %H:%M:%S")
    print(s_date, e_date)
    
    sub_df = sub_df_return(article_df, s_date, e_date)
    sub_vector = sub_vector_return(sub_df)
    article_dict = cluster_return(sub_vector, sub_df)
    
    if len(merge_dict) == 0:
        merge_dict = article_dict.copy()
    else:
        current_dict = article_dict.copy()
        merge_dict = merge(merge_dict, current_dict).copy()
    
    print("**************",s_date,"~",e_date,"군집화 결과********************")
    item_list = [[key, len(value)] for key, value in merge_dict.items()]
    item_list = sorted(item_list, key=lambda x : x[1], reverse=True)
    for item in item_list[:5]:
        cluster_num = item[0]
        cluster = merge_dict[cluster_num]
        for article in cluster:
            print(article['title'], article['datetime'])
        print("\n")
    start_date += timedelta(hours=1)

해당 코드의 군집화 & 병합의 결과 샘플을 살펴보자

자세한 코드와 실행결과는 여기서!

 

GitHub - hoonzinope/hobby_coding: 취미로 하는 코딩

취미로 하는 코딩. Contribute to hoonzinope/hobby_coding development by creating an account on GitHub.

github.com

 

문제가 하나 있다.

위와 같이 병합&추가 시 군집의 크기가 무한정 커지게 된다.

어느정도 시간이 지난 기사의 경우 군집내에서 지우는 작업이 필요하다. 이번에 나같은 경우는 datetime을 기준으로 잡기로 했다.

현재 수행하는 시간 - 4시간 전 기사의 경우 군집에서 삭제해 준다.

# 시작일,종료일 설정
start = "2022-05-21 00:00:00"
last = "2022-05-21 23:00:00"

# 시작일, 종료일 datetime 으로 변환
start_date = datetime.strptime(start, "%Y-%m-%d %H:%M:%S")
last_date = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")

merge_dict = {}
current_dict = {}

# 종료일 까지 반복
while start_date <= last_date:
    
    end_date = start_date + timedelta(hours=1) + timedelta(seconds=-1)
    
    s_date = start_date.strftime("%Y-%m-%d %H:%M:%S")
    e_date = end_date.strftime("%Y-%m-%d %H:%M:%S")
    print(s_date, e_date)
    
    sub_df = sub_df_return(article_df, s_date, e_date)
    sub_vector = sub_vector_return(sub_df)
    article_dict = dict(cluster_return(sub_vector, sub_df))
    if len(merge_dict) == 0:
        merge_dict = article_dict.copy()
    else:
        current_dict = article_dict.copy()
        merge_dict = merge(merge_dict, current_dict).copy()
    
    print("**************",s_date,"~",e_date,"군집화 결과********************")
    item_list = [[key, len(value)] for key, value in merge_dict.items()]
    item_list = sorted(item_list, key=lambda x : x[1], reverse=True)
    for item in item_list[:5]:
        cluster_num = item[0]
        cluster = merge_dict[cluster_num]
        for article in cluster:
            print(article['title'],"\t",article['datetime'])
        print("\n")
        
    # 시간이 오래된 값은 제거 필요
    remove_cluster_list = []
    for cluster_num in merge_dict:
        cluster = merge_dict[cluster_num]
        remove_list = []
        # 시간 차이를 구한뒤, 리스트의 index를 저장
        for i, article in enumerate(cluster):
            article_time = article['datetime']
            article_time = datetime.strptime(article_time, "%Y-%m-%d %H:%M:%S")
            
            diff = article_time - start_date
            if diff.seconds > 14400: # 4시간 이상 차이날 경우 제거 필요
                remove_list.append(i)
                
        # 지워야할 index를 pop 해준다
        remove_list.reverse() # 뒤에서 부터 지워야 하기 때문에 reverse 함수
        for i in remove_list:
            cluster.pop(i)
            
        # 지우고 보니 cluster에 남아있는게 없다면 해당 클러스터 제거
        if len(cluster) == 0:
            remove_cluster_list.append(cluster_num)
            
    # 지우고 보니 cluster에 남아있는게 없다면 해당 클러스터 제거
    for cluster_num in remove_cluster_list:
        del merge_dict[cluster_num]
        
    start_date += timedelta(hours=1)

추가된 부분은 아래와 같다.

# 시간이 오래된 값은 제거 필요
remove_cluster_list = []
for cluster_num in merge_dict:
    cluster = merge_dict[cluster_num]
    remove_list = []
    # 시간 차이를 구한뒤, 리스트의 index를 저장
    for i, article in enumerate(cluster):
        article_time = article['datetime']
        article_time = datetime.strptime(article_time, "%Y-%m-%d %H:%M:%S")
        
        diff = article_time - start_date
        if diff.seconds > 14400: # 4시간 이상 차이날 경우 제거 필요
            remove_list.append(i)
            
    # 지워야할 index를 pop 해준다
    remove_list.reverse() # 뒤에서 부터 지워야 하기 때문에 reverse 함수
    for i in remove_list:
        cluster.pop(i)
        
    # 지우고 보니 cluster에 남아있는게 없다면 해당 클러스터 제거
    if len(cluster) == 0:
        remove_cluster_list.append(cluster_num)
        
# 지우고 보니 cluster에 남아있는게 없다면 해당 클러스터 제거
for cluster_num in remove_cluster_list:
    del merge_dict[cluster_num]

지워도 결과가 매우 길기 때문에 깃허브에 가서 찬찬히 살펴보는걸 추천한다.

https://github.com/hoonzinope/hobby_coding/blob/main/python/clustering_online_news.ipynb

 

GitHub - hoonzinope/hobby_coding: 취미로 하는 코딩

취미로 하는 코딩. Contribute to hoonzinope/hobby_coding development by creating an account on GitHub.

github.com

 

또 하나 중요작업이 하나 남았다. 위 코드를 보면 클러스터 예시를 보여줄때 클러스터 개수가 큰순으로 보여주고 있는데, 기사 개수가 많다고 마냥 중요한 이슈라고 볼수 있는가? 에 대해서는 조금더 고려가 필요하다.

네이버 블로그 에 소개된 기사 중요도 의 경우,

클러스터의 기사 개수 & 이번 시간대 추가된 기사 개수를 고려해 중요도를 판단하고 있다.

(단순히 기사 공급자의 관점에서 처리한 것만을 볼때…, 네이버는 해당 특징 말고도 여러 관점에서 판단하고 있다.)

그럼 추가해보자.

# 기존 클러스터의 중요도 판단
item_list = [[key, len(value)] for key, value in merge_dict.items()]
item_list = sorted(item_list, key=lambda x : x[1], reverse=True)
for item in item_list[:5]:
    cluster_num = item[0]
    cluster = merge_dict[cluster_num]
    for article in cluster:
        print(article['title'],"\t",article['datetime'])
    print("\n")


#*********************************************


# 변경된 클러스터의 중요도 판단 -> 기존 자리에 추가
weight = 0.63
item_list = []
for key, value in merge_dict.items():
    cluster = value
    cluster_size = len(cluster)
    cluster_score = 0
    for article in cluster:
        article_time = article['datetime']
        article_time = datetime.strptime(article_time, "%Y-%m-%d %H:%M:%S")
        if start_date <= article_time and article_time <= end_date:
            cluster_score += 1
    cluster_score = weight * cluster_size + (1-weight) * cluster_score
    item_list.append([key, cluster_score])
        
item_list = sorted(item_list, key=lambda x : x[1], reverse=True)
for item in item_list[:5]:
    cluster_num = item[0]
    cluster = merge_dict[cluster_num]
    for article in cluster:
        print(article['title'],"\t",article['datetime'])
    print("\n")

 

결과를 살펴봤을때 내가 보기엔 크게 달라진 점이 없어보인다…

(하지만 크기가 비슷한 경우라면 유의미한 구분점이 되지 않을까 싶다.)

 

결론!

이렇게 점진적 군집화를 수행해 보았다. 특정 시간대의 작은 데이터만 가져와 로직을 수행함으로써 연산의 부담을 낮추고, 바뀌는 주요 뉴스에 대해서도 보여줄수 있다는 장점이 있다.

좋은 결과를 위해 로직의 각 단계에서 조정을 잘 하면 주요 이슈 검출 프로세스로써 사용할 수 있을 것 같다.

(회사 제품에 탑재해봐야지!!)

반응형

댓글