imhamburger 님의 블로그

데이터엔지니어 부트캠프 - 유사 공연 추천시스템 cosine_sim[idx] 수정하기 (24주차) 본문

데이터엔지니어 부트캠프

데이터엔지니어 부트캠프 - 유사 공연 추천시스템 cosine_sim[idx] 수정하기 (24주차)

imhamburger 2024. 12. 28. 15:26

지난주 유사 공연 추천시스템 구현 중에 다음과 같은 에러가 있었다. (지난글)

 

에러메세지

Traceback (most recent call last):
  File "/Users/seon-u/TU-tech/ml/src/ml/main.py", line 43, in <module>
    similar_performances = get_top_similar_performances(cosine_sim, performances)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/seon-u/TU-tech/ml/src/ml/utils.py", line 85, in get_top_similar_performances
    performance_similarities = cosine_sim[idx]  # 해당 공연과 다른 공연들의 유사도
                               ~~~~~~~~~~^^^^^
IndexError: index 5534 is out of bounds for axis 0 with size 5534

 

발생이유

 

에러의 원인은 cosine_sim[idx]에서 idx 값이 cosine_sim 배열의 크기를 초과하기 때문이다.

cosine_sim의 크기가 (5534, 5534)인데, idx 값이 5534이므로 인덱스 범위를 벗어났다. 배열 인덱스는 0부터 시작하기 때문에 유효한 인덱스 범위는 0 ~ 5533이다.

 

따라서 나는 해결방법으로 반복문 내부에 조건문 if idx >= len(cosine_sim)을 추가하였다.
이 조건을 통해, cosine_sim 배열에서 참조할 수 없는 인덱스를 건너뛰게 된다.

 

그런데 애초에 왜 인덱스 크기가 다른지 고민을 안해보았던 것 같다.

그리고 원본 데이터와의 크기가 차이가 있어, 디버깅을 해보니 공연 간 유사도 계산 크기 자체가 적었다.

 

원본 데이터를 살펴보니, 유사 공연 유사도 계산은 description 컬럼을 가져와 벡터화하고 계산하는데 description 자체가 없는 공연들이 있거나, Word2Vec 모듈이 학습을 못하는 경우가 있었다.

 

따라서 인덱스 크기가 안맞아 에러가 났던 것이었고, 원인을 찾을 수 있었다.

 

그래서 나는 전처리 함수부터 다시 수정하였다.

 

 

1-1. 전처리 (수정 전)

def preprocess(text):
    if text is None:
        return []  # None은 빈 리스트로 처리
    
    text = text.lower()  # 소문자화
    text = ''.join([char for char in text if char not in string.punctuation])  # 구두점 제거
    tokens = okt.nouns(text)  # 형태소 분석 후 명사 추출
    return tokens

 

1-2. 전처리 (수정 후)

def preprocess(text):
    """
    텍스트를 소문자화하고 구두점을 제거한 후, 형태소 분석하여 명사만 추출하는 함수
    """
    if not text or not isinstance(text, str):
        return ["default"]  # 기본값 처리
    text = text.lower()
    text = ''.join([char for char in text if char not in string.punctuation])
    tokens = okt.nouns(text)
    return tokens if tokens else ["default"]  # 빈 리스트 방지

 

기존의 text가 없다면 빈 리스트로 처리했었는데, 기본값 "default"으로 반환하도록 수정하였다.

 

 

2-1. Word2Vec 모델 학습 (수정 전)

def train_word2vec_model(descriptions):
    processed_descriptions = [preprocess(description) for description in descriptions]
    model = Word2Vec(processed_descriptions, vector_size=100, window=5, min_count=1, workers=4)
    return model

 

 

2-2. Word2Vec 모델 학습 (수정 후)

def train_word2vec_model(descriptions):
    """
    전처리된 설명들로 Word2Vec 모델을 학습하는 함수
    """
    # 데이터 검증
    none_count = sum(1 for desc in descriptions if desc is None)
    empty_count = sum(1 for desc in descriptions if isinstance(desc, str) and not desc.strip())
    print(f"None 개수: {none_count}, 빈 문자열 개수: {empty_count}")

    # 전처리
    processed_descriptions = [preprocess(description) for description in descriptions]
    print(f"전처리된 데이터 크기: {len(processed_descriptions)}")

    # 전처리 후 빈 리스트 검증
    empty_processed = [desc for desc in processed_descriptions if not desc]
    print(f"전처리 후 빈 리스트 개수: {len(empty_processed)}")

    # 빈 리스트 기본값 추가
    processed_descriptions = [desc if desc else ["default"] for desc in processed_descriptions]
    print(f"Word2Vec 학습 데이터 크기: {len(processed_descriptions)}")


    model = Word2Vec(processed_descriptions, vector_size=100, window=5, min_count=1, workers=4)

    return model

 

입력 데이터에서 None 값이나 빈 문자열의 개수를 확인하고 전처리 후 빈 리스트가 된 데이터를 검사한다.

전처리 후 빈 리스트로 남은 데이터를 기본값 ["default"]로 설정하여 Word2Vec 학습 데이터에서 제외되지 않도록 보장한다.

 

예상결과는 다음과 같다.

 

입력데이터

performances = [
    {"description": "클래식 음악 공연"},
    {"description": "재즈 공연"},
    {"description": None},
    {"description": "  "},  # 빈 문자열
    {"description": "뮤지컬의 매력에 빠져보세요"}
]

 

결과

[
    "클래식 음악 공연",
    "재즈 공연",
    "default",  # None 처리
    "default",  # 빈 문자열 처리
    "뮤지컬의 매력에 빠져보세요"
]

 

 

3-1. 단어 벡터 추출 (수정 전)

def get_word_vector(model, word):
    try:
        return model.wv[word]
    except KeyError:
        print(f"단어 '{word}'는 모델에 존재하지 않습니다.")
        return None

 

 

3-2. 단어 벡터 추출 (수정 후)

def get_average_vector(model, tokens):
    """
    주어진 단어들의 벡터 평균을 계산하는 함수
    """
    vectors = [model.wv[word] for word in tokens if word in model.wv]
    return np.mean(vectors, axis=0) if vectors else np.zeros(model.vector_size)

 

입력된 tokens 리스트에서 Word2Vec 모델에 존재하는 단어의 벡터를 가져온다.

입력된 tokens 리스트에서 모델에 존재하는 단어가 없는 경우, 0으로 채워진 벡터를 반환하여 데이터 손실을 방지한다.

 

 

4. 단어 평균 벡터 계산

def get_average_vector(model, tokens):
    """
    주어진 단어들의 벡터 평균을 계산하는 함수
    """
    vectors = []
    for word in tokens:
        if word in model.wv:
            vectors.append(model.wv[word])
    if len(vectors) == 0:
        return np.zeros(model.vector_size)
    return np.mean(vectors, axis=0)

 

 

5. 코사인 유사도 계산

def calculate_cosine_similarity(model, descriptions):
    """
    공연 설명별로 평균 벡터를 구하고, 코사인 유사도를 계산하는 함수
    """
    processed_descriptions = [preprocess(description) for description in descriptions]
    print(f"전처리된 설명 데이터 크기: {len(processed_descriptions)} / 원본 데이터 크기: {len(descriptions)}")
    
    # 각 공연 설명에 대한 평균 벡터 구하기
    description_vectors = np.array([get_average_vector(model, desc) for desc in processed_descriptions])
    print(f"벡터화된 데이터 크기: {len(description_vectors)}")
    print(f"0 벡터 개수: {sum(1 for vec in description_vectors if np.all(vec == 0))}")
    
    # 코사인 유사도 계산
    cosine_sim = cosine_similarity(description_vectors)
    print(f"코사인 유사도 계산 완료: {cosine_sim.shape}")

    return cosine_sim

 

 

6. 유사 공연 추천

def get_top_similar_performances(cosine_sim: np.ndarray, performances, top_n=3, threshold=0.98, days_limit=90):
    similar_performances = []

    # 각 공연에 대해 유사한 공연들을 찾기
    for idx, performance in enumerate(performances):
        # 유효성 검사: 인덱스 초과 시 기본값 처리
        if idx >= len(cosine_sim):
            print(f"Index {idx} is out of bounds for cosine_sim with size {len(cosine_sim)}")
            performance_similarities = np.zeros(len(performances))  # 기본값으로 유사도 0 배열 사용
        else:
            performance_similarities = cosine_sim[idx]
            
         # start_date가 None인 경우 건너뛰기
        performance_start_date_str = performance.get('start_date', None)
        if performance_start_date_str is None:
            continue

        performance_start_date = datetime.strptime(performance_start_date_str, '%Y.%m.%d')  # 공연 시작 날짜
        performance_similarities = cosine_sim[idx]  # 해당 공연과 다른 공연들의 유사도
        performance_region = performance.get('region', None)

        # 공연의 start_date 기준으로 90일 이내인 공연만 필터링
        date_limit = performance_start_date + timedelta(days=days_limit)
        
        # 유사도 높은 순으로 정렬하되, 자기 자신은 제외하고, threshold 이상의 유사도를 가진 공연들만 필터링
        similar_performances_idx = np.argsort(performance_similarities)[::-1]  # 유사도 높은 순으로 정렬
        top_similar = []
        
        for similar_idx in similar_performances_idx:
            # 자기 자신은 제외하고, 유사도 threshold 이하이어야 하며, 같은 지역이어야 함
            if similar_idx != idx and performance_similarities[similar_idx] < threshold:
                similar_performance = performances[similar_idx]

                similar_performance_start_date_str = similar_performance.get('start_date', None)

                # start_date가 None인 경우 건너뛰기
                if similar_performance_start_date_str is None:
                    continue

                similar_performance_start_date = datetime.strptime(similar_performance_start_date_str, '%Y.%m.%d') 
                #similar_performance_start_date = datetime.strptime(similar_performance['start_date'], '%Y.%m.%d')
                similar_performance_region = similar_performance.get('region', None)
                
                if performance_region != similar_performance_region:
                    continue
                
                # 지역이 동일하고, 90일 이내인 경우만 유사 공연으로 추가
                if performance_start_date <= similar_performance_start_date <= date_limit:
                    top_similar.append(similar_performance)
                
                # 이미 top_n개 만큼 찾았으면 추가 중지
                if len(top_similar) >= top_n:
                    break
        
        # 결과 리스트에 유사 공연 추가
        similar_performances.append(top_similar)
    
    return similar_performances

 

 

이렇게 코드를 바꿔주니 모든 원본 데이터에 대한 코사인 유사도 계산을 할 수 있었다.

 

우리 프로젝트의 경우, 완벽한 추천시스템을 구현하는 것이 목표가 아니라서 이런식으로 해결을 하였지만 정확성을 추구한다면 직접 학습을 시키는 프로세스가 필요할 것이다.

 

 

추천시스템 코드 흐름

  1. 공연설명을 형태소 분석기로 명사만 추출 (전처리)
  2. Word2Vec 모델을 사용하여 단어를 벡터로 변환
  3. 각 공연 설명에 대한 평균 벡터 계산
  4. 코사인 유사도를 기반으로 공연 간 유사도 측정
  5. 유사도가 높은 공연을 필터링 및 추천

 

4개의 샘플 코사인 유사도 결과