이번 포스트는 GPT와 Bard를 활용한 도서 난이도 책정과 연결되는 부분이 있습니다.

 

 

GPT와 Bard를 활용한 도서 난이도 책정

안녕하세요! 저는 요즘 GPT 없이는 아무것도 못하는 삶을 살고 있어요. 심지어 시험 볼 때도 오픈 GPT인 세상,, 과학 기술의 발전이 또 이렇게 성큼성큼,, 이런 상황에 프로젝트를 할 때 GPT를 안 쓰

hanadoolsae.tistory.com


 
안녕하세요 !!!!!!
 
저번 포스트에서 제작한 도서 데이터를 바탕으로
도서 추천 알고리즘을 본격적으로 만들어보고자 합니다 !
 
그 전에 저희 프로젝트에 생긴 가장 큰 변화를 짚고 넘어가자면
바로 타겟유저가 '청소년'이 된 것입니다 !
 
회원가입시 진행되는 어휘력/문해력 테스트의 난이도 및 동기부여 등에 대해
국어국문과 교수님들께 자문을 구하는 과정에서 충분한 논의를 거친 뒤 '청소년'으로 메인 타겟을 정하게 되었어요.
이로인해 회원가입시 관심 전공을 입력 받아, 전공과 도서간의 유사도를 추천 알고리즘에 포함하게 되었답니다.
 
그럼 바로 저희 가이드북의 추천 알고리즘을 설명하겠습니다 !
 
* 추천 알고리즘이 무엇인지 알고싶다면 아래 더보기를 클릭해주세요 !

더보기

간단히 알고 가는 추천 알고리즘 ~!

 

추천 알고리즘에는 크게 2가지 종류가 있습니다.

하나는 사용자 기반 협업 필터링이고 또 하나는 아이템 기반 협업 필터링입니다.

 

 

저희 서비스에 적용해서 설명해보자면,

사용자 기반 협업 필터링'나'와 비슷한 다른 학생들의 선호도서를 바탕으로 추천이 이루어지고

아이템 기반 협업 필터링내가 좋아하는'도서'와 비슷한 도서를 바탕으로 추천이 이루어집니다.

 

사람이 비슷해야하는지 도서가 비슷해야하는지의 차이라고 생각하면 됩니다 !


GUIDE:BOOK의 추천 알고리즘

 
0. 도서 데이터


저희의 타겟 유저가 청소년으로 변경됨에 따라
도서 데이터에 청소년 권장도서대학교 추천도서를 반영하려고 했습니다.
따라서 문화체육관광부 추천 도서교육청 추천 도서를 활용하였으며
최신도서 정보를 포함하기 위해 알라딘의 장르별 베스트셀러를 함께 수집하였습니다.

이때, 도서 난이도는 bard를 통해 얻었고,
도서 이미지, 개요, 페이지수는 웹 크롤링으로 획득하였는데
selenium을 이용해서 각 isbn에 해당하는 도서에 접근해 정보를 가져왔습니다.
 
 


 1. 도서 임베딩


처음에는 알고 있는 모델이 word2vec뿐이라 이것을 사용하였는데,
한국어 맞춤 모델이 아니라 그런지 학습에 한계가 있더라고요.
 
그래서 한국어 맞춤 모델을 여러가지 찾아 보았습니다.

KoBERT(KoreanBidirectional Encoder Representations from Transformers)
SKT에서 공개한 위키피디아, 뉴스 등에서 수집한 5천만개의 문장으로 학습된 모델입니다. 한국어의 불규칙한 언어 변화의 특성을 반영하기 위해 데이터 기반 토큰화(SentencePiece tokenizer) 기법을 적용하였으며 vocab 크기는 8002, 모델의 파라미터 크기는 92M입니다.

KLUE-BERT
KLUE-BERT는 벤치마크 데이터인 KLUE에서 베이스라인으로 사용되었던 모델로, 모두의 말뭉치, CC-100-Kor, 나무위키, 뉴스, 청원 등 문서에서 추출한 63GB의 데이터로 학습되었습니다. Morpheme-based Subword Tokenizer를 사용하였으며, vocab size는32,000이고 모델의 크기는 111M입니다.

* 출처: https://www.letr.ai/blog/tech-20221124

 
이 두 가지 모델이 제가 활용했던 모델이고,
확실히 KLUE-BERT가 사이즈가 더 커서 학습이 잘 진행되었습니다.
그래서 최종적으로 KLUE-BERT를 사용하게 되었습니다.



 
2. 독서 중단 사유 반영


저희의 도서 추천 서비스의 핵심 기능의
첫째는 어휘력/문해력 기반 추천이고
둘째는 중단 사유 반영입니다.

기존 레퍼런스들은 독서중과 완독만을 기록하지 독서를 중단한 사례는 기록하지 않았습니다.

저희는 추천을 받을 때 좋아하는 것을 추천 받는 것도 중요하지만
싫어하는 것을 배제하는 것도 중요하다 생각하여 이 부분을 고려하였습니다.
 

def get_interruption_reasons(member_id):
    # 사용자가 중단한 도서 목록을 가져오기
    interrupted_books = MemberMyBook.query.filter_by(
        member_entity_id=member_id, status=2  # status 2가 중단임
    ).all()

    interruption_reasons = {
        'high_difficulty': [],
        'low_difficulty': [],
        'many_pages': [],
        'few_pages': [],
        'disliked_genre': []
    }

    # 중단 사유별로 도서의 특성을 분류
    for book in interrupted_books:
        if book.status_detail == '1':
            interruption_reasons['high_difficulty'].append(book.book_entity_id)
        elif book.status_detail == '2':
            interruption_reasons['low_difficulty'].append(book.book_entity_id)
        elif book.status_detail == '3':
            interruption_reasons['many_pages'].append(book.book_entity_id)
        elif book.status_detail == '4':
            interruption_reasons['few_pages'].append(book.book_entity_id)
        elif book.status_detail == '5':
            interruption_reasons['disliked_genre'].append(book.book_entity_id)
            logger.info(f"Book with ID {book.book_entity_id} added to disliked_genre for member {member_id}")

    # 모든 책을 확인한 후에 사유별 분류를 반환
    return interruption_reasons

def adjust_recommendations(member_id, recommendations):
    # 중단한 도서의 사유 가져오기
    interruption_reasons = get_interruption_reasons(member_id)
    # 싫어하는 장르의 장르 번호 업데이트
    interruption_reasons = update_disliked_genre_with_genre_numbers(member_id, interruption_reasons)

    # 평균 난이도와 페이지 수 계산
    avg_difficulty, avg_page_count = get_average_difficulty_and_page_count(interruption_reasons)

    # 추천 도서 목록 조정
    adjusted_recommendations = []
    for rec in recommendations:
        book_id = rec['id']
        book_difficulty = rec['bookDifficulty']
        book_page_count = rec['bookPage']
        book_genre_id = rec['bookGenre']

        # 난이도가 0인 책은 난이도 관련 필터링에서 제외
        if book_difficulty != '0':
            # 사용자가 난이도 때문에 중단한 책 제외
            if avg_difficulty is not None and difficulty_levels.get(book_difficulty, 3) > avg_difficulty:
                logger.info(f'Book with ID {book_id} excluded due to higher difficulty than preferred.')
                continue

        # 페이지 수에 따른 조정을 추가
        if avg_page_count is not None:
            # 사용자가 페이지 수가 많은 책을 중단한 경우, 평균보다 많은 페이지 수를 가진 책 제외
            if book_page_count > avg_page_count and book_id in interruption_reasons['many_pages']:
                logger.info(f'Book with ID {book_id} excluded due to having more pages than preferred.')
                continue
            # 사용자가 페이지 수가 적은 책을 중단한 경우, 평균보다 적은 페이지 수를 가진 책 제외
            if book_page_count < avg_page_count and book_id in interruption_reasons['few_pages']:
                logger.info(f'Book with ID {book_id} excluded due to having fewer pages than preferred.')
                continue

            # 중단한 장르와 같은 장르의 책 제외
        if book_genre_id in interruption_reasons['disliked_genre']:
            logger.info(
                f'Book with ID {book_id} excluded due to being of a disliked genre with genre number {book_genre_id}.')
            continue

            # 조정된 추천 도서를 목록에 추가
        adjusted_recommendations.append(rec)

    logger.info(f'Returning {len(adjusted_recommendations)} adjusted recommendations for member ID {member_id}')
    return adjusted_recommendations

저희 데이터베이스 중 member_mybook_table에서 status 컬럼에 숫자 2가 들어간 도서가 독서 중단된 도서이고,
status_detail 컬럼에 적힌 숫자들이 각각의 이유를 나타냅니다.
 

이 추천 목록에서 독서 중단 사유로 난이도와 장르를 주입하겠습니다.
경제/경영 부문 책을 장르가 맘에 안든다고 선택하고, 난이도가 4인 책이 어렵다고 하였습니다.
 

그 후 바뀐 추천 목록입니다.
경제/경영 도서가 사라졌고 난이도가 4이상인 도서도 사라진 것을 확인할 수 있습니다.
 

현재는 단순하게 도서의 난이도, 두께, 장르에 대한 사유만 고려하여 반영하였는데
이 부분은 추후에 좀 더 자세하게 사유를 반영할 수 있도록 수정하면 좋을 것 같습니다.


 
3. 유저 정보 기반

유저의 레벨,학년,선호 장르 기반 도서 추천입니다.
 
예시로 보여드리는 류한아라는 이 학생은 level3이 나왔으며, '과학'과 '소설'을 선택한 컴공에 관심있는 고등학교 1학년입니다.
테스트 결과가 3이 나와 첫 줄에는 난이도가 3으로 분류된 도서가 추천되었으며,
고등학교 1학년이기에 둘째줄에는 난이도가 4인 도서, 그리고 마지막 줄은 난이도가 2인 도서가 추천되었습니다.
 
초등학생부터 성인까지 회원가입이 가능하기에
본인의 레벨을 기준으로 초등학생~중3까지는 레벨보다 한 단계 낮은 도서가 상위에 뜨도록
고1~성인까지는 본인의 레벨보다 한 단께 높은 도서가 상위에 뜨도록 정렬되었습니다.
 
추천도서가 6개 이상이 나온다면 새로고침을 할 때마다 새로운 도서가 추천됩니다.
 
 


4. 유저의 내서재 기반

아까 류한아란 학생의 마이페이지입니다.
과학, 소설, 컴퓨터 IT와 관련된 도서들을 완독하고 찜한 모습입니다.
 

내 서재에 있는 도서 중 '우주탐사의 물리학'이라는 도서가 랜덤으로 선택되어
유사도가 높은 도서들이 출력되었습니다.
 

random_book_title_encoded = request.args.get('randomLikedOrCompletedBookTitle')

# random_book_title_encoded가 None인 경우를 처리
if random_book_title_encoded is not None:
    random_book_title = unquote(random_book_title_encoded)
else:
    random_book_title = ""  # 또는 다른 적절한 기본값 설정

logging.info(f"Received request for member_id: {member_id} with book title: {random_book_title}")

recommendations = []

if random_book_title:
    try:
        # 데이터프레임에서 해당 책의 인덱스를 찾기
        filtered_books = books[books['book_title'] == random_book_title]
        if filtered_books.empty:
            logging.warning(f"Book not found: {random_book_title}")
            return jsonify({"error": "Book not found"}), 404

        book = filtered_books.iloc[0]
        book_id = book['id']
        logging.info(f"Found book: {book['book_title']} with ID: {book_id}")

        # id를 사용하여 해당 책의 임베딩 인덱스를 찾기
        book_index = np.where(book_embeddings['id'] == book_id)[0]
        if not book_index.size:
            logging.warning(f"Book embedding not found for ID: {book_id}")
            return jsonify({"error": "Book embedding not found"}), 404

        # 임베딩 추출 부분 수정
        book_embedding = book_embeddings['embedding'][book_index][0]

        # NumPy 배열로 변환
        all_embeddings = np.array([emb for emb in book_embeddings['embedding']])

        similarity_scores = cosine_similarity([book_embedding], all_embeddings)[0]

스프링에서 플라스크로 내 서재 속 랜덤 도서 한 권의 제목을 url에 인코딩하여 보냈기에
디코딩 한 후 도서 제목을 이용해 도서 id를 찾고, 이 id를 바탕으로 임베딩 유사도를 파악하였습니다.

 

랜덤 도서의 book_id를 보내면 숫자라 따로 인코딩/디코딩을 안 해도 되는데 제목(한글)로 보내서 굳이 인코딩을 한 이유는

스프링부트에서 랜덤 도서명을 그대로 클라이언트측 코드에서 출력해서 사용하느라

변수를 두 개 만들어 그 도서명으로 다시 sql에 접근해 book_id를 확인해서 flask로 전송하느니

도서명을 그냥 flask에서 인코딩된 상태로 수신하는게 빠를 것 같았습니다.

어차피 flask에선 book_id로 받든 도서명으로 받든 똑같은 book테이블에 접근하는 거라 똑같거든요.
 

로그를 보면 122번 멤버(류한아)의 랜덤 도서로 우주탐사의 물리학이 플라스크 서버로 넘어온 것을 확인하였습니다.
그리고 이 도서 번호를 book_table에서 확인한 후 위에서 언급한 도서간의 임베딩 numpy 파일을 통해
유사도가 높은 도서들을 상위 6권 출력합니다.
 


이런식으로 내 서재에 있는 여러 도서 중 랜덤으로 책이 잘 추천되는 것을 볼 수 있습니다.
 
이렇게 아이템 기반 협업 필터링을 진행하였습니다.
꽤나 괜찮게 책이 추천되어 뿌듯합니다. 하하.
 

 

5. 유저와 전공&학년이 같은 다른 유저 기반

이 도서들은 류한아란 학생과 전공/학년을 동일하게 선택한 유저들이
완독을 하였고 위시리스트에 담아놓은 책들을 기반으로 사용자 기반 협업 필터링을 진행한 결과입니다.

 

 
위의 사진들이 컴퓨터공학전공에 관심이 있는 1학년 학생들 내 서재 중 일부입니다.
이런 식으로 류한아 학생과 전공과 학년이 똑같은 학생들의 내서재를 탐색한 뒤 협업 필터링을 진행합니다.
 

# 모든 비슷한 사용자의 도서 목록과 중복 횟수 계산
all_user_books = {}
for user_id in similar_members_ids:
    user_books = MemberMyBook.query.filter(
        MemberMyBook.member_entity_id == user_id,
        MemberMyBook.status == 1
    ).all()
    user_liked_books = MemberMyLike.query.filter(
        MemberMyLike.member_entity_id == user_id
    ).all()

    for book in user_books + user_liked_books:
        book_id = book.book_entity_id
        all_user_books[book_id] = all_user_books.get(book_id, 0) + 1

# 협업 필터링을 통해 찾은 도서 중 현재 사용자의 도서 목록과 중복되지 않는 것들만 추천 목록에 추가
collaborative_book_ids = set(all_user_books.keys()) - current_user_books
collaborative_recommendations = Book.query.filter(Book.id.in_(collaborative_book_ids)).all()
recommended_books_dicts = [book.as_dict() for book in collaborative_recommendations]

# 책 정보에 중복 횟수 추가
for book in recommended_books_dicts:
    book_id = book['id']
    book['duplicate_count'] = all_user_books.get(book_id, 0)

# 도서 목록을 중복 횟수와 전공 유사도에 따라 정렬
final_recommendations = sorted(recommended_books_dicts,
                               key=lambda x: (-x['duplicate_count'], -x.get('major_similarity', 0)))

# 로깅 및 상위 6권 추천 도서 반환
top_six_recommendations = final_recommendations[:6]
current_app.logger.info("Top 6 recommendations (based on collaborative filtering and major similarity):")
for book in top_six_recommendations:
    current_app.logger.info(
        f"Book ID: {book['id']}, Duplicate Count: {book['duplicate_count']}, Major Similarity: {book.get('major_similarity', 0)}")

 
류한아 학생과 같은 전공/학년을 선택한 다른 유저들이 선택한 도서중
류한아 학생의 내 서재 목록과 비슷한 유저들의 도서를 확인하고,
그 중 다른 유저들과 비교했을 때 중복이 많은(대중적으로 좋아하는) 도서를 확인한 뒤,
마지막으로 도서와 전공 유사도를 반영하여 순서를 정렬하였습니다.
 
 


보완점 및 아쉬운 점

 
1. 전공과 도서간의 미미한 유사도
전공과 도서간의 유사도를 산출할 때, 전공 설명과 도서의 개요를 전처리 후 임베딩을 진행하였습니다.
이 과정에서 나온 임베딩 값이 추천 알고리즘에 유의미한 차이를 제공하진 않더라고요.
이 부분은 전공 설명을 좀 더 자세하고 길게 가져와 여러 키워드들을 생성한 후 도서와 돌려봐야할 것 같습니다.
 
2. 적은 유저 데이터
추천 알고리즘의 핵심이 사실 여러 유저가 사용을 해야하는 것인데
유저 풀이 좁다보니 추천 결과가 아름답지 않았습니다.
하지만 이건 1년 기한의 프로젝트에서는 어쩔 수 없는 현상이라 생각하기에
추후에 이 프로젝트를 좀 더 구체화 시키고 사업화(?)할 계획이 있다면 해결해야할 문제같습니다.
 
3. 시시한 추천 알고리즘
개인적으로는 추천 알고리즘이 너무 시시하다고 생각합니다.
추천 알고리즘 자체를 구현하는데 시간을 많이 허비해
좀 더 특이하고 다양한 알고리즘을 개발하기엔 힘들었던 것이 아쉽네요.
더 다양한 인자를 활용해서 추천 알고리즘에 반영하면 어떨까 싶은데
레퍼런스에서 감정을 토대로 추천하는 서비스가 있었어서
이 부분도 재밌을 것 같습니다.
 
4. 느린 속도
클라이언트 -> 스프링 -> 데베 -> 스프링 -> 플라스크 -> 데베 -> 스프링 -> 클라이언트
이런 흐름으로 진행되다 보니 속도가 느립니다.
현재는 스프링과 플라스크 양쪽 모두 데베에 접근하도록 코드를 짰지만,
가능하다면 스프링에서만 데베에 접근하도록 변경해서 속도를 높이고 싶습니다.
이 부분은 좀 더 알아봐야 할 것 같네요.


1년간의 졸업 프로젝트 동안 많이 힘들었지만,,
얻어가는 것들이 많아서 참 뿌듯하네요.
 
물론, 아직 부족한 점들이 있긴 하지만,
이것들은 차차 보완해 나갈 수 있지 않을까 싶습니다.
 
이번 프로젝트르 통해
"구글링과 gpt와 함께라면 불가능은 없다." 를 뼈저리게 느껴서 더욱 해낼 수 있을 것 같네요.
 
 

 
궁금하신 점이나 오류가 나는 부분은 댓글 달아주시면 확인해보겠습니다 !
끝까지 읽어주셔서 감사합니다 !!

'2023 캡스톤 디자인' 카테고리의 다른 글

GPT와 Bard를 활용한 도서 난이도 책정  (4) 2023.05.23

+ Recent posts