{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "6ae49949-7ad0-49e2-b4ed-ba97799ddc95",
   "metadata": {},
   "source": [
    "# 16 추천 시스템의 원리와 ALS 행렬 분해\n",
    "\n",
    "## 16.1 \"이 노래도 좋아하실 것 같아요\" - 추천 시스템의 두 방식\n",
    "\n",
    "유튜브(YouTube)를 열었을 때 평소 관심 있던 주제의 영상이 메인 화면에 나타나거나, 음원 스트리밍 서비스가 오늘 내 기분에 맞는 플레이리스트를 만들어 준 경험이 있으실 것입니다. 이처럼 사용자의 취향을 미리 파악하고 상품을 제안하는 기술을 추천 시스템(Recommendation System)이라고 합니다.\n",
    "\n",
    "앞서 제15장에서 배운 연관 규칙 분석(Association Rule Analysis)이 \"기저귀를 사는 사람은 맥주도 산다\"처럼 전체 데이터 속에 숨은 '항목 간의 보편적인 규칙'을 찾는 일이었다면, 이번 장에서 다룰 추천 시스템은 \"바로 이 사람(User)이 다음에 무엇을 원할까?\"라는 개인화에 초점을 맞춥니다.\n",
    "\n",
    "현재 데이터 분석 실무에서 널리 활용되는 추천 시스템은 크게 두 가지 갈래로 나뉩니다. 바로 콘텐츠 기반 필터링(Content-based Filtering)과 협업 필터링(Collaborative Filtering)입니다.\n",
    "\n",
    "### 16.1.1 콘텐츠 기반 필터링(Content-based Filtering): 상품의 특징으로 추천하기\n",
    "\n",
    "콘텐츠 기반 필터링의 아이디어는 단순합니다. \"사용자가 과거에 좋아했던 상품과 '비슷한 특징을 가진 상품'을 추천하자\"는 것입니다. 여기서 기준이 되는 것은 상품 그 자체가 가지고 있는 속성 정보(Metadata)입니다.\n",
    "\n",
    "예를 들어 어떤 사용자가 SF 영화인 <인터스텔라>에 높은 평점을 주었다고 가정해 보겠습니다. 추천 시스템은 이 영화의 특징을 분석합니다.\n",
    "\n",
    "* **장르:** SF, 우주\n",
    "* **감독:** 크리스토퍼 놀란\n",
    "* **주연:** 매튜 맥커너히\n",
    "\n",
    "이제 시스템은 데이터베이스를 검색하여 이 특징들과 겹치는 다른 영화를 찾습니다. 감독이 같으면서 장르가 유사한 <인셉션>이나, 우주를 배경으로 하는 <마션>을 후보로 선정하여 사용자에게 추천하는 방식입니다.\n",
    "\n",
    "이 방식의 장점은 다른 사람의 데이터가 필요 없다는 점입니다. 오직 '나의 과거 기록'과 '상품의 정보'만 있으면 작동하므로, 갓 출시된 신상품이라도 특징 분석만 끝났다면 곧바로 추천 목록에 올릴 수 있습니다.\n",
    "\n",
    "하지만 한계도 존재합니다. 상품의 특징에만 집중하다 보니 **사용자의 취향을 한곳에 갇히게 만든다는 점**입니다. SF 영화만 보던 사람에게는 계속해서 SF 영화만 추천하게 되므로, \"우연히 멜로 영화를 봤는데 생각보다 재미있었다\"와 같은 취향의 확장을 기대하기 어렵습니다. 또한 상품의 특징을 데이터로 정밀하게 정의해 두지 않으면 추천의 정확도가 낮아집니다.\n",
    "\n",
    "\n",
    "### 16.1.2 협업 필터링(Collaborative Filtering): 비슷한 취향의 이웃을 찾아 추천하기\n",
    "\n",
    "앞선 방식의 한계를 보완하기 위해 등장한 것이 협업 필터링(Collaborative Filtering)입니다. 이 방식은 상품의 특징을 전혀 분석하지 않습니다. 대신 \"나와 취향이 비슷한 다른 사람들이 좋아한 상품을 추천하자\"는 아이디어에 기반합니다. 현실에서 친구에게 \"너 이번에 새로 나온 그 영화 봤어? 우리 둘이 평소 영화 취향이 비슷하니까 너도 꼭 봐봐\"라고 말하는 것과 같습니다.\n",
    "\n",
    "작동 과정을 가상 상황으로 이해해 보겠습니다.\n",
    "\n",
    "* **사용자 A의 취향:** 액션 영화(좋아요), 공포 영화(싫어요), **코미디 영화(좋아요)**\n",
    "* **사용자 B의 취향:** 액션 영화(좋아요), 공포 영화(싫어요), **(아직 관람 전)**\n",
    "\n",
    "알고리즘은 데이터를 통해 '사용자 A'와 '사용자 B'의 평가 패턴이 유사하다는 것을 알아챕니다. 그렇다면 시스템은 사용자 B에게 자연스럽게 **코미디 영화**를 추천합니다. 두 사람을 '취향이 비슷한 이웃'으로 묶어 협업하게 만든 것입니다.\n",
    "\n",
    "협업 필터링의 장점은 **예상치 못한 발견의 즐거움**을 줄 수 있다는 점입니다. 평소 SF 영화만 보던 사람이라도, 그와 취향이 비슷한 이웃들이 최근에 역사 다큐멘터리를 즐겨 봤다면 시스템은 이 다큐멘터리를 추천합니다. 상품의 장르가 전혀 달라도 '사람들의 행동 패턴'이 연결되어 있다면 추천이 가능해지는 것입니다.\n",
    "\n",
    "하지만 이 방식에는 콜드 스타트(Cold Start)라는 약점이 있습니다. 새로 가입해 과거 데이터가 없는 신규 사용자나, 아직 아무도 평가하지 않은 신상품은 이웃과 비교할 기준이 없기 때문에 추천 목록에서 제외됩니다. 또한 전체 상품 수에 비해 사용자가 실제로 소비하고 평가한 상품은 소수에 불과한 경우가 많아, 데이터 행렬의 대부분이 빈칸으로 남는 현상이 발생합니다.\n",
    "\n",
    "바로 이 '비어 있는 수많은 칸'을 수학적으로 계산하여 합리적인 예측값으로 채워 넣는 방법 중 하나가 다음 절에서 배울 **ALS(Alternating Least Squares)** 알고리즘입니다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e63be10f-871b-41a6-8a27-1c6fe6d4982b",
   "metadata": {},
   "source": [
    "## 16.2 데이터 행렬 속 숨은 취향 찾기\n",
    "\n",
    "협업 필터링(Collaborative Filtering)을 실제로 구현하려고 하면 커다란 장벽에 부딪히게 됩니다. 바로 데이터의 '빈칸'입니다. 인터넷 쇼핑몰에 수만 개의 상품이 있어도, 한 명의 소비자가 구매하는 상품은 많아야 수십 개에 불과합니다. 스트리밍 서비스에 수백만 곡의 노래가 있어도 우리가 듣는 곡은 일부에 지나지 않습니다.\n",
    "\n",
    "이처럼 사용자를 행(Row)으로 하고 상품을 열(Column)로 하여 데이터를 나열해 보면, 대부분의 칸이 비어 있는 거대한 행렬이 만들어집니다. 데이터 과학에서는 이를 희소 행렬(Sparse Matrix)이라고 부릅니다. 이 거대하고 듬성듬성한 행렬 속에서 사용자의 숨은 취향을 찾아내기 위해 어떤 방법을 사용해야 할까요?\n",
    "\n",
    "\n",
    "### 16.2.1 행렬 분해(Matrix Factorization)\n",
    "\n",
    "행렬 분해(Matrix Factorization, MF)는 거대한 행렬을 두 개의 작은 행렬로 쪼개어 분석하는 기법입니다. 자연수 15를 $3 \\times 5$로 소인수분해하면 15라는 숫자 뒤에 숨은 구성 요소를 알 수 있듯이, 데이터 행렬도 분해하면 그 안에 숨겨진 본질적인 요소를 파악할 수 있다는 아이디어에서 출발합니다.\n",
    "\n",
    "이해를 돕기 위해 영화 추천 시스템을 예로 들어 보겠습니다. 우리에게는 [사용자 $\\times$ 영화]로 이루어진 커다란 평점 행렬이 있습니다. 행렬 분해 알고리즘은 이 행렬을 다음 두 가지 작은 행렬로 쪼갭니다.\n",
    "\n",
    "$$R \\approx P \\times Q \\tag{16-1}$$\n",
    "\n",
    "* **$R$ ([사용자 $\\times$ 영화] 행렬):** 사용자가 영화에 내린 평점 데이터가 들어 있는 전체 행렬입니다.\n",
    "* **$P$ ([사용자 $\\times$ 숨은 특징] 행렬):** 각 사용자가 액션, 로맨스 같은 숨은 특징(잠재 요인)을 얼마나 선호하는지 나타내는 행렬입니다.\n",
    "* **$Q$ ([숨은 특징 $\\times$ 영화] 행렬):** 각 영화가 해당 숨은 특징을 얼마나 가지고 있는지 나타내는 행렬입니다.\n",
    "\n",
    "\n",
    "여기서 말하는 '숨은 특징'을 데이터 과학에서는 잠재 요인(Latent Factor)이라고 부릅니다. 컴퓨터는 \"이 영화는 액션 점수 4.5점, 로맨스 점수 0.2점\"이라는 식으로 우리가 직접 지정하지 않은 데이터의 숨은 특징을 스스로 찾아냅니다.\n",
    "\n",
    "행렬 분해가 완료되면 빈칸을 채우는 일은 간단해집니다. 어떤 사용자의 취향 점수와 영화의 특징 점수를 곱하면, 그 사용자가 아직 보지 않은 영화에 내릴 평점을 예측할 수 있게 됩니다. 계산 결과 예측 평점이 높게 나온 영화들을 골라 사용자 화면에 띄워주는 것이 행렬 분해 기반 추천 시스템의 원리입니다.\n",
    "\n",
    "\n",
    "### 16.2.2 명시적 피드백(Explicit Feedback)과 암묵적 피드백(Implicit Feedback)\n",
    "\n",
    "행렬 분해를 수행하기 전에 데이터 분석가가 반드시 확인해야 할 사항이 있습니다. 바로 우리가 수집한 데이터가 어떤 성격을 띠고 있느냐 하는 점입니다. 추천 시스템에서 활용하는 사용자의 반응 데이터는 크게 두 가지로 나뉩니다.\n",
    "\n",
    "#### 명시적 피드백(Explicit Feedback)\n",
    "\n",
    "사용자가 자신의 선호도를 **직접적이고 명확하게 표현한 데이터**입니다.\n",
    "\n",
    "* **예시:** 별점 5점 만점에 4점 주기, 영화에 '좋아요' 또는 '싫어요' 누르기, 리뷰 점수 남기기\n",
    "* **특징:** 취향이 숫자로 확실하게 드러나므로 분석하기가 비교적 수월합니다. 하지만 사용자들이 번거로움을 감수하고 평가를 남겨야 하므로 전체 데이터 중에서 얻을 수 있는 양이 매우 적습니다.\n",
    "\n",
    "#### 암묵적 피드백(Implicit Feedback)\n",
    "\n",
    "사용자가 직접 점수를 매기지는 않았지만, **행동을 통해 간접적으로 취향을 유추할 수 있는 데이터**입니다.\n",
    "\n",
    "* **예시:** 특정 상품의 상세 페이지 클릭 횟수, 음원 반복 재생 횟수, 영상 시청 시간, 검색어 입력 이력\n",
    "* **특징:** 사용자가 의도하지 않아도 웹사이트나 앱을 이용하는 과정에서 자연스럽게 쌓이므로 데이터의 양이 엄청나게 많습니다.\n",
    "\n",
    "실무에서는 명시적 피드백보다 암묵적 피드백 데이터를 다루는 경우가 훨씬 많습니다. 고객들은 물건을 살 때마다 별점을 남기지는 않지만, 관심 있는 상품은 여러 번 클릭하고 오래 머무르기 때문입니다.\n",
    "\n",
    "다만 암묵적 피드백은 '싫어함'의 기준이 모호하다는 분석상의 어려움이 있습니다. 어떤 사용자가 옷을 구매하지 않았을 때, 그것이 마음에 들지 않아서인지 아니면 단지 상품의 존재를 몰라서인지 데이터 자체만으로는 구별하기 어렵습니다.\n",
    "\n",
    "다음 절에서 배울 **ALS(Alternating Least Squares)** 알고리즘은 이러한 암묵적 피드백 데이터의 특성을 반영하여, 데이터가 없는 빈칸의 의미를 수학적으로 함께 계산할 수 있도록 고안된 대표적인 알고리즘입니다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3af9eb16-eb90-4c04-b208-684fad3e7408",
   "metadata": {},
   "source": [
    "### 16.2.3 암묵적 피드백 점수(Value) 설계 방법\n",
    "\n",
    "암묵적 피드백 점수는 단일 특징보다 여러 행동 특징을 조합한 '선호의 신뢰도 점수(Confidence Score)'를 활용합니다. 세 가지 경우를 통해 암묵적 피드백 점수를 설계하는 구체적인 예시를 보여드립니다.\n",
    "\n",
    "**1) 행동의 단계별 가중치 부여 (도메인별 깔대기 모델)**\n",
    "\n",
    "서비스 내에서 발생하는 행동의 깊이에 따라 가중치를 다르게 설정하여 합산합니다.\n",
    "\n",
    "* **미디어/콘텐츠 서비스 (OTT, 웹툰 등)**\n",
    "\n",
    "$$\\text{Implicit Value} = (\\text{클릭} \\times 1) + (\\text{끝까지 시청} \\times 5) + (\\text{북마크} \\times 3) \\tag{16-2}$$\n",
    "\n",
    "\n",
    "* **커머스 서비스 (쇼핑몰 등)**\n",
    "\n",
    "$$\\text{Implicit Value} = (\\text{상세페이지 조회} \\times 1) + (\\text{장바구니 담기} \\times 3) + (\\text{구매하기} \\times 10) \\tag{16-3}$$\n",
    "\n",
    "```python\n",
    "# 행동별 가중치를 매핑하여 합산하는 방식\n",
    "df['implicit_value'] = (df['click'] * 1) + (df['watch_full'] * 5) + (df['bookmark'] * 3)\n",
    "```\n",
    "\n",
    "\n",
    "**2) 노이즈 제거를 위한 필터링 및 정규화**\n",
    "\n",
    "* **체류 시간(Dwell Time)의 분위수 활용:** 머문 시간에 점수를 선형으로 부여하면 왜곡이 생깁니다. 로그 변환($\\log(1 + \\text{체류시간})$)을 적용하거나, 평균 체류 시간을 기준으로 상위 20%에게만 가중치를 부여합니다.\n",
    "* **의도치 않은 클릭 제거:** 잘못 누르고 바로 이탈한 경우(체류 시간 3초 미만)는 데이터셋에서 제외하거나 역가중치를 부여합니다.\n",
    "\n",
    "```python\n",
    "import numpy as np\n",
    "\n",
    "# 3초 미만 이탈 데이터 제거 (노이즈 필터링)\n",
    "df = df[df['dwell_time'] >= 3]\n",
    "\n",
    "# 로그 변환을 통한 체류 시간 정규화 (alpha는 가중치 하이퍼파라미터)\n",
    "alpha = 40\n",
    "df['implicit_value'] = 1 + alpha * np.log1p(df['dwell_time'])\n",
    "\n",
    "```\n",
    "\n",
    "\n",
    "**3) 컨텍스트와 시간의 반영**\n",
    "\n",
    "* **시간 감쇠(Time Decay):** 3년 전 구매와 어제 구매의 가중치는 달라야 합니다. 지수 감쇠 함수를 활용해 최근 행동일수록 `implicit_feedback` 열의 값을 높게 책정합니다.\n",
    "* **주기성 및 빈도(Frequency):** 정기적으로 구매하는 생필품과 일회성으로 조회한 가구는 신뢰도 산정 방식을 다르게 적용합니다.\n",
    "\n",
    "```python\n",
    "# 가장 최근 시점을 기준으로 경과일 계산\n",
    "max_date = df['timestamp'].max()\n",
    "df['days_elapsed'] = (max_date - df['timestamp']).dt.days\n",
    "\n",
    "# 반감기(half_life)를 180일로 설정하여 시간 감쇠 가중치 계산\n",
    "half_life = 180\n",
    "df['time_weight'] = 0.5 ** (df['days_elapsed'] / half_life)\n",
    "\n",
    "# 기본 점수에 시간 가중치와 구매 빈도를 반영\n",
    "df['implicit_value'] = df['base_score'] * df['time_weight'] * df['purchase_frequency']\n",
    "\n",
    "```\n",
    "\n",
    "추천 시스템을 실무에 적용할 때 주의할 점은 **데이터 전처리(Feature Engineering) 단계에서 비즈니스 로직을 암묵적 피드백 점수로 바꾸는 과정**입니다. 데이터 과학자는 기획자, 마케터와 소통하며 유저가 만족했을 때 하는 행동을 명확하게 정의해야 합니다. 이렇게 정한 점수로 `csr_matrix`를 만들고 모델을 평가(MAP, NDCG 등)하면서, 우리 서비스에 잘 맞는 점수 산식을 찾아내야 합니다.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e0006ecf-abc1-490c-bc2d-b1d9c018fcff",
   "metadata": {},
   "source": [
    "## 16.3 ALS(Alternating Least Squares) 알고리즘\n",
    "\n",
    "앞서 우리는 거대한 데이터 행렬을 [사용자 $\\times$ 잠재 요인] 행렬과 [잠재 요인 $\\times$ 상품] 행렬이라는 두 개의 작은 행렬로 쪼개는 개념을 배웠습니다. 그렇다면 컴퓨터는 이 두 행렬에 들어갈 수많은 취향 점수와 특징 점수를 어떻게 찾아내는 것일까요?\n",
    "\n",
    "가장 직관적인 방법은 두 행렬의 값을 동시에 조금씩 수정해 나가며 정답을 찾는 것입니다. 하지만 두 행렬의 미지수를 동시에 계산하려고 하면 수학적으로 문제가 복잡해지고 연산량이 급격하게 늘어납니다. 이 난제를 해결하기 위해 등장한 아이디어가 바로 **ALS(Alternating Least Squares, 교대 최소제곱법)** 알고리즘입니다.\n",
    "\n",
    "\n",
    "### 16.3.1 교대로 학습하기\n",
    "\n",
    "ALS 알고리즘의 핵심 원리는 이름에 들어간 '교대(Alternating)'라는 단어에 모두 담겨 있습니다. 복잡한 문제를 한 번에 풀기 어려우니, 한쪽을 고정해 두고 다른 한쪽을 먼저 푼 뒤, 다시 반대로 교대해가며 문제를 푸는 방식입니다. 마치 놀이터에서 두 사람이 번갈아 발을 구르며 움직이는 시소 타기와 비슷합니다.\n",
    "\n",
    "작동 과정은 다음과 같은 순서로 진행됩니다.\n",
    "\n",
    "1. **초기화 단계:** 처음에는 아무런 정보가 없으므로, 두 작은 행렬([사용자 행렬]과 [상품 행렬])에 임의의 임의의 숫자를 채워 넣습니다.\n",
    "2. **첫 번째 교대 (사용자 고정, 상품 학습):** 우선 [사용자 행렬]을 상수로 취급하여 그대로 고정합니다. 이 상태에서 실제 데이터와 가장 비슷해지도록 [상품 행렬]의 값들을 수학적으로 계산하여 업데이트합니다. 한쪽이 고정되면 남은 한쪽은 비교적 간단한 일차 방정식(최소제곱법, Least Squares)으로 쉽게 풀립니다.\n",
    "3. **두 번째 교대 (상품 고정, 사용자 학습):** 이번에는 방금 계산한 따끈따끈한 [상품 행렬]을 고정합니다. 그리고 반대로 [사용자 행렬]의 값들을 다시 계산하여 업데이트합니다.\n",
    "4. **반복 단계:** 이 과정을 만족할 만한 수준에 도달할 때까지 2번과 3번을 계속해서 교대로 반복(Iteration)합니다.\n",
    "\n",
    "처음에는 엉터리 숫자로 시작했더라도, 시소를 타듯 번갈아 가며 한쪽을 기준으로 다른 쪽을 최적화하다 보면 점차 실제 사용자의 평점이나 행동 패턴을 정확하게 예측하는 완성된 행렬에 도달하게 됩니다.\n",
    "\n",
    "\n",
    "### 16.3.2 큰 규모 데이터에서도 알고리즘이 잘 동작하는 이유\n",
    "\n",
    "추천 시스템 분야에서 ALS가 널리 쓰이는 가장 큰 이유는 대용량 데이터를 처리할 때 보여주는 **압도적인 계산 효율성** 덕분입니다.\n",
    "\n",
    "머신러닝과 딥러닝에서 모델을 학습시킬 때 주로 사용하는 방식은 경사하강법(Gradient Descent, GD) 계열의 알고리즘입니다. 경사하강법은 전체 데이터를 조금씩 보면서 정답을 향해 조금씩 기어가는 방식입니다. 이 방식은 데이터가 수천만 건 수준으로 커지면 컴퓨터가 연산의 무게를 견디지 못하고 지치게 됩니다. 행렬 분해의 특성상 미지수가 너무 많아 학습 속도가 매우 느려지기 때문입니다.\n",
    "\n",
    "반면 ALS는 다음과 같은 장점을 가지고 있습니다.\n",
    "\n",
    "* **독립적인 병렬 연산:** 2단계나 3단계를 수행할 때, 한쪽 행렬이 고정되면 각 사용자(또는 각 상품)의 계산은 다른 사용자에게 아무런 영향을 주지 않는 독립된 문제가 됩니다. 즉, 1번 사용자의 취향을 계산하는 동안 2번 사용자의 취향을 동시에 계산해도 아무런 문제가 없습니다. 덕분에 수많은 컴퓨터 프로세서에 연산을 나누어 맡기는 병렬 처리(Parallel Processing)가 가능해집니다.\n",
    "* **빠른 수렴 속도:** 경사하강법처럼 조금씩 정답을 찾아 헤매는 것이 아니라, 매 단계마다 수학적 공식을 통해 곧바로 최적의 지점으로 도약(수렴)하므로 반복 횟수 자체가 매우 적습니다. 보통 10회에서 20회 안팎의 교대 반복만으로도 훌륭한 성능을 냅니다.\n",
    "\n",
    "이러한 장점 덕분에 ALS는 수천만 명의 회원과 수백만 개의 상품을 보유한 글로벌 스트리밍 기업이나 대형 이커머스 플랫폼에서도 지치지 않고 빠르게 추천 모델을 만들어낼 수 있는 실무형 알고리즘으로 자리 잡았습니다."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8c1a8d1b-8bdd-4c9b-92dd-ce886b832b68",
   "metadata": {},
   "source": [
    "## 16.4 [실습] 사용자 맞춤형 추천 시스템 구현\n",
    "\n",
    "콘텐츠 기반 필터링은 상품의 속성 데이터(텍스트, 태그 등)를 정제하는 전처리 과정이 복잡하고 취향의 파편화라는 한계가 있어, 실무에서는 대규모 사용자 행동 데이터를 효율적으로 처리하는 협업 필터링 기법을 더 널리 사용합니다. 따라서 이번 실습에서는 협업 필터링의 대표적인 알고리즘인 ALS를 활용해 추천 시스템을 구현해 보겠습니다.\n",
    "이번 실습에서는 행렬 분해 기반 추천 시스템에 널리 쓰이는 오픈소스 라이브러리인 **implicit**을 사용합니다. 대규모 데이터에서도 연산 속도가 매우 빠르고, 단 몇 줄의 코드로 ALS 알고리즘을 수행할 수 있어 실무에서 애용되는 라이브러리입니다.\n",
    "\n",
    "\n",
    "### 16.4.1 희소 행렬(Sparse Matrix) 데이터 준비와 전처리\n",
    "\n",
    "ALS 모델을 학습시키기 위해서는 먼저 [사용자 $\\times$ 상품] 형태의 행렬 데이터를 준비해야 합니다. 하지만 앞서 언급했듯이 대부분의 칸이 비어 있는 **희소 행렬(Sparse Matrix)** 형태이므로, 이를 일반적인 2차원 배열이나 DataFrame(데이터프레임)으로 그대로 저장하면 메모리가 심하게 낭비됩니다. 파이썬에서는 이를 효율적으로 다루기 위해 `scipy.sparse` 패키지를 활용합니다.\n",
    "\n",
    "우리가 사용할 가상의 데이터는 사용자가 특정 상품을 몇 번 클릭하거나 구매했는지를 나타내는 암묵적 피드백(Implicit Feedback) 데이터입니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "c5d7a018-367e-4e31-8ed3-4ae00bd8f213",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "생성된 희소 행렬 크기: (5, 15)\n",
      "실제 기록된 데이터 개수: 10개\n"
     ]
    }
   ],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "from scipy.sparse import csr_matrix\n",
    "\n",
    "# 1. 가상의 암묵적 피드백 데이터 생성 (사용자 ID, 상품 ID, 행동 횟수)\n",
    "data = {\n",
    "    'user_id': [0, 0, 1, 1, 1, 2, 2, 3, 3, 4],\n",
    "    'item_id': [10, 12, 11, 12, 14, 10, 13, 11, 14, 12],\n",
    "    'plays':   [ 5,  1,  4,  2,  5,  1,  4,  5,  3,  2]  # 클릭 또는 재생 횟수\n",
    "}\n",
    "df = pd.DataFrame(data)\n",
    "\n",
    "# 2. 행렬의 크기를 정의하기 위한 고유 ID 개수 파악\n",
    "num_users = df['user_id'].nunique()\n",
    "num_items = df['item_id'].max() + 1  # 인덱스 범위를 고려한 크기 설정\n",
    "\n",
    "# 3. CSR(Compressed Sparse Row) 행렬 형태로 변환\n",
    "# implicit 라이브러리는 [상품 x 사용자] 또는 [사용자 x 상품] 형태를 요구하므로 형태에 유의합니다.\n",
    "user_items = csr_matrix(\n",
    "    (df['plays'].astype(float), (df['user_id'], df['item_id'])),\n",
    "    shape=(num_users, num_items)\n",
    ")\n",
    "\n",
    "print(f\"생성된 희소 행렬 크기: {user_items.shape}\")\n",
    "print(f\"실제 기록된 데이터 개수: {user_items.nnz}개\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6098375d-61ff-4158-99fe-7d3bbd2f35be",
   "metadata": {},
   "source": [
    "`csr_matrix`(Compressed Sparse Row 행렬)은 희소 행렬(Sparse Matrix)을 효율적으로 저장하고 연산하기 위해 사용하는자료구조입니다. 함수 인수로 `(data, (rows, cols))` 형태로 인자를 전달합니다. `data`는 암묵적 피드백 값(`float`)이며, `rows`는 사용자 `cols`는 아이템 인덱스로 0부터 시작하는 정수값을 갖습니다.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ad877a8b-4461-4e9e-a62f-d914843dc1ba",
   "metadata": {},
   "source": [
    "### 16.4.2 Implicit 라이브러리를 활용한 ALS 모델 학습과 평가\n",
    "\n",
    "이제 전처리가 끝난 희소 행렬을 ALS 모델에 넣어 학습을 진행하고, 모델의 성능을 측정하는 평가지표를 출력해 보겠습니다. 이어서 특정 사용자에게 맞춤형 상품을 추천하는 과정까지 구현합니다.\n",
    "\n",
    "*(※ 실습을 진행하기 전 터미널에 `pip install implicit`을 입력하여 라이브러리를 먼저 설치해야 합니다.)*\n",
    "\n",
    "ALS 모델을 생성하는 다음 코드의 의미를 살펴보겠습니다.\n",
    "\n",
    "```python\n",
    "# 2. ALS 모델 인스턴스 생성\n",
    "model = implicit.als.AlternatingLeastSquares(\n",
    "    factors=20, \n",
    "    regularization=0.1, \n",
    "    iterations=15, \n",
    "    random_state=42\n",
    ")\n",
    "```\n",
    "\n",
    "매개변수(Parameter)의 의미는 다음과 같습니다.\n",
    "\n",
    "* **`factors` (20):** 잠재 요인(Latent Factor) 수 혹은 식 (16-1)의 숨은 특징의 수입니다.\n",
    "* **`iterations` (15):** 모델 학습을 위해 계산을 몇 번 반복할지 지정하는 횟수입니다.\n",
    "* **`random_state` (42):** 결과를 실행할 때마다 동일하게 유지하기 위해 설정하는 난수 고정 값입니다.\n",
    "* **`regularization` (0.1):** 모델의 복잡도를 제어하여 과적합을 방지하는 정규화 매개변수입니다.\n",
    "앞서 살펴본 행렬 분해 식에 정규화가 포함되면 모델은 다음과 같은 형태의 목표를 가지고 학습합니다.\n",
    "\n",
    "$$\\text{손실} = (\\text{실제 평점} - \\text{예측 평점})^2 + \\lambda \\times (\\text{숨은 특징 값들의 크기}) \\tag{16-4}$$\n",
    "\n",
    "여기서 $\\lambda$는 코드의 규제 항인 `regularization=0.1`을 의미합니다. 이 값을 키우면 숨은 특징 값이 커지지 않게 잡아주고, 줄이면 오차를 줄이는 데 더 집중하게 됩니다. `regularization` 값을 비워두거나 0으로 설정하면 ALS 모델이 학습 데이터에만 과하게 맞춰지게 됩니다. 그 결과 사용자나 영화의 숨은 특징(factors) 값이 불안정하게 커질 수 있으니 적절한 값을 설정해주는 것이 중요합니다.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "43698609-12e8-4198-9d8c-9264b54a7bb1",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- ALS 모델 학습 시작 ---\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 15/15 [00:00<00:00, 101.48it/s]"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- ALS 모델 학습 완료 ---\n",
      "\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- [모델 성능 평가 지표 (Top-10)] ---\n",
      "Precision@10 (정밀도) : 0.4238\n",
      "MAP@10       (평균정밀도): 0.2778\n",
      "NDCG@10      (순위반영)  : 0.4214\n",
      "\n",
      "--- [사용자 0번 추천 결과] ---\n",
      "추천 상품 ID: 273  |  추천 점수(선호 확률): 1.1156\n",
      "추천 상품 ID: 865  |  추천 점수(선호 확률): 1.0965\n",
      "추천 상품 ID: 1524  |  추천 점수(선호 확률): 1.0873\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "os.environ['OPENBLAS_NUM_THREADS'] = '1'  # 다른 라이브러리를 부르기 전에 가장 먼저 선언\n",
    "\n",
    "import implicit\n",
    "import pandas as pd\n",
    "from implicit.evaluation import train_test_split, ranking_metrics_at_k\n",
    "\n",
    "# 1. 데이터를 학습용(Train)과 검증용(Test)으로 분할\n",
    "train_items, test_items = train_test_split(user_items, train_percentage=0.8, random_state=42)\n",
    "\n",
    "# 2. ALS 모델 인스턴스 생성\n",
    "model = implicit.als.AlternatingLeastSquares(\n",
    "    factors=20, \n",
    "    regularization=0.1, \n",
    "    iterations=15, \n",
    "    random_state=42\n",
    ")\n",
    "\n",
    "# 3. 모델 학습 수행 (학습용 데이터 입력)\n",
    "print(\"--- ALS 모델 학습 시작 ---\")\n",
    "model.fit(train_items)\n",
    "print(\"--- ALS 모델 학습 완료 ---\\n\")\n",
    "\n",
    "# 4. 모델 성능 평가지표 계산 (상위 10개 추천 기준: K=10)\n",
    "metrics = ranking_metrics_at_k(model, train_items, test_items, K=10, show_progress=False)\n",
    "\n",
    "print(\"--- [모델 성능 평가 지표 (Top-10)] ---\")\n",
    "print(f\"Precision@10 (정밀도) : {metrics.get('precision', metrics.get('precision_at_k', 0)):.4f}\")\n",
    "print(f\"MAP@10       (평균정밀도): {metrics.get('map', metrics.get('map_at_k', 0)):.4f}\")\n",
    "print(f\"NDCG@10      (순위반영)  : {metrics.get('ndcg', metrics.get('ndcg_at_k', 0)):.4f}\\n\")\n",
    "\n",
    "# 5. 0번 사용자(user_id=0)를 위한 맞춤형 상품 3개 추천받기\n",
    "user_id = 0\n",
    "recommendations, scores = model.recommend(\n",
    "    userid=user_id, \n",
    "    user_items=user_items[user_id], \n",
    "    N=3\n",
    ")\n",
    "\n",
    "# 6. 결과 출력하기\n",
    "print(f\"--- [사용자 {user_id}번 추천 결과] ---\")\n",
    "for item, score in zip(recommendations, scores):\n",
    "    print(f\"추천 상품 ID: {item:2d}  |  추천 점수(선호 확률): {score:.4f}\")\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf0c9f2c-d71d-4996-95fe-1308e3886bc7",
   "metadata": {},
   "source": [
    "#### 분석 결과 해석과 비즈니스 인사이트 도출\n",
    "\n",
    "코드를 실행하면 알고리즘이 데이터를 학습한 뒤, 모델의 성능을 나타내는 세 가지 지표와 지정한 사용자의 맞춤형 추천 상품을 차례대로 출력합니다. 추천 시스템의 성적표와 결과를 해석하는 방법은 다음과 같습니다.\n",
    "\n",
    "* **Precision@10 (정밀도):** 시스템이 사용자에게 추천한 상위 10개의 상품 중, 사용자가 실제로 관심을 가질 만한 상품이 평균적으로 몇 개 포함되어 있는지를 나타냅니다. 현재 결과인 `0.4238`은 10개 중 약 4개 이상의 상품을 정확하게 맞추었다는 의미로, 추천 시스템의 정확도가 준수함을 보여줍니다.\n",
    "* **NDCG@10 (순위반영):** 추천한 상품들이 사용자의 취향 순서에 맞게 잘 정렬되었는지 평가하는 지표입니다. 사용자가 가장 좋아할 만한 상품이 추천 목록의 '앞쪽(상위권)'에 배치될수록 1에 가까운 높은 점수가 나옵니다. `0.4214`라는 점수는 사용자가 매력적으로 느낄 만한 상품을 앞 장에 적절히 노출하고 있음을 뜻합니다.\n",
    "* **추천 점수의 의미:** 결과에 출력되는 점수는 일반적인 별점(예: 4.5점)이 아닙니다. 이 사용자가 해당 상품을 **선호할 확률이나 경향성**을 나타내는 상대적인 수치입니다. 따라서 점수가 높은 상품을 골라 이커머스의 '당신을 위한 맞춤 상품' 영역이나 알림 서비스에 연동하면, 단순 인기 상품을 보여줄 때보다 고객의 클릭률(CTR)과 구매 전환율을 효과적으로 끌어올릴 수 있습니다.\n",
    "\n",
    "> **💡 재현율(Recall) 지표가 보이지 않는 이유**\n",
    "> 일반적인 머신러닝 분류 문제에서는 정밀도와 함께 재현율(Recall)을 필수로 확인합니다. 하지만 암묵적 피드백 데이터를 사용하는 추천 시스템에서는 사용자가 좋아할 만한 전체 상품 중 몇 개를 맞추었는지 보는 재현율보다, 당장 화면에 노출되는 상위 10개 중에서 몇 개를 적중시켰는지 보는 **정밀도(Precision)** 와 앞쪽에 잘 정렬했는지를 뜻하는 **NDCG** 지표를 중심으로 성적을 평가하는 것이 더 실무적입니다.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "845d438c-b6a7-4805-b7bb-3725db6c3aaf",
   "metadata": {},
   "source": [
    "## 16.5 실습과제: 영화 평점 데이터를 활용한 개인화 추천 시스템 구축\n",
    "\n",
    "이번 과제에서는 추천 시스템 알고리즘의 성능 평가에 전 세계 데이터 과학자들이 가장 널리 사용하는 영화 평점 데이터 세트(MovieLens 데이터)의 일부를 활용하여, 실제 사용자의 암묵적 피드백 데이터를 전처리하고 ALS 기반의 맞춤형 영화를 추천하는 파이프라인을 구축해 봅니다.\n",
    "\n",
    "### 16.5.1 [MovieLens 데이터 세트]\n",
    "영화 평점에 관한 MovieLens 데이터 세트는 미국 미네소타 대학교의 데이터 과학 연구 그룹인 GroupLens(그룹렌즈)에서 공식적으로 관리하고 무료로 제공하고 있습니다. 실습 데이터 다운로드 방법은 다음과 같습니다.\n",
    "\n",
    "1. GroupLens 공식 홈페이지([https://grouplens.org/datasets/movielens/](https://grouplens.org/datasets/movielens/))에 접속합니다.\n",
    "2. 화면을 내려 **MovieLens 100k dataset** 항목을 찾습니다.\n",
    "3. `ml-100k.zip` 링크를 클릭하여 다운로드한 후, 실습 폴더에 압축을 풀어줍니다.\n",
    "4. 파일 중 `u.data`는 사용자 평점 데이터이며, `u.item`은 영화 제목 정보를 담고 있습니다.\n",
    "\n",
    "\n",
    "### 16.5.2 [과제 시나리오]\n",
    "\n",
    "당신은 동영상 스트리밍 서비스(OTT)의 데이터 분석가입니다. 사용자들이 영화를 시청한 횟수 데이터를 바탕으로, 각 사용자에게 '아직 보지 않았지만 가장 좋아할 확률이 높은' 영화 5개를 골라 홈 화면에 추천하는 코드를 완성해야 합니다.\n",
    "\n",
    "\n",
    "\n",
    "### 16.5.3 [단계별 수행 가이드]\n",
    "\n",
    "#### 단계 1: 분석 데이터 다운로드 및 불러오기\n",
    "\n",
    "아래 코드를 실행하여 데이터프레임을 생성하세요."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "e5dd1e47-93a5-4f3a-bb73-19da86b66e20",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- MovieLens 실제 데이터 병합 완료 ---\n",
      "전체 데이터 행 개수: 100,000개\n",
      "\n",
      "--- 원본 데이터 샘플 ---\n",
      "   user_id                 movie_title  view_count\n",
      "0      196                Kolya (1996)           1\n",
      "1      186    L.A. Confidential (1997)           1\n",
      "2       22         Heavyweights (1994)           1\n",
      "3      244  Legends of the Fall (1994)           1\n",
      "4      166         Jackie Brown (1997)           1\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "# 실무 팁: OpenBLAS 스레드 충돌 방지 설정\n",
    "os.environ['OPENBLAS_NUM_THREADS'] = '1'\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "from scipy.sparse import csr_matrix\n",
    "import implicit\n",
    "\n",
    "# 1. 사용자 평점 데이터(u.data) 불러오기\n",
    "# u.data 파일은 탭(\\t)으로 구분되어 있으며, 헤더가 없으므로 직접 지정합니다.\n",
    "data_path = 'ml-100k/u.data'\n",
    "data_cols = ['user_id', 'movie_id', 'rating', 'timestamp']\n",
    "ratings_df = pd.read_csv(data_path, sep='\\t', names=data_cols, encoding='utf-8')\n",
    "\n",
    "# 2. 영화 정보 데이터(u.item) 불러오기\n",
    "# u.item 파일은 세로선(|)으로 구분되어 있으며, 앞의 두 칸(ID, 제목)만 추출합니다.\n",
    "item_path = 'ml-100k/u.item'\n",
    "item_cols = ['movie_id', 'movie_title']\n",
    "items_df = pd.read_csv(item_path, sep='|', names=item_cols, usecols=[0, 1], encoding='ISO-8859-1')\n",
    "\n",
    "# 3. 두 데이터프레임을 movie_id 기준으로 병합(Merge)\n",
    "df = pd.merge(ratings_df, items_df, on='movie_id')\n",
    "\n",
    "# 4. 암묵적 피드백(Implicit Feedback) 형태로 변환\n",
    "# 사용자가 평점을 매겼다는 것은 해당 영화를 '시청했다'는 의미이므로 view_count를 모두 1로 설정합니다.\n",
    "df['view_count'] = 1\n",
    "\n",
    "# 필요한 열만 추출하여 최종 데이터프레임 구성\n",
    "df = df[['user_id', 'movie_title', 'view_count']]\n",
    "\n",
    "print(\"--- MovieLens 실제 데이터 병합 완료 ---\")\n",
    "print(f\"전체 데이터 행 개수: {len(df):,}개\")\n",
    "print(\"\\n--- 원본 데이터 샘플 ---\")\n",
    "print(df.head())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d96a3b80-53b3-4e08-8816-b7d333114da6",
   "metadata": {},
   "source": [
    "#### 단계 2: 데이터 인덱스를 0부터 시작하는 고유 ID로 매핑하기\n",
    "\n",
    "MovieLens의 원본 `movie_id`와 `user_id`는 1번부터 시작하지만, 파이썬의 행렬 인덱스는 0번부터 시작합니다. 희소 행렬 연산 오류를 방지하고 메모리를 효율적으로 쓰기 위해 데이터를 0부터 시작하는 범주형(Category) 인덱스로 새로 변환해 주어야 합니다.\n",
    "\n",
    "* **미션 2-1:** `df['movie_title']`을 범주형(Category) 데이터로 변환하고, 생성된 0 기반의 고유 코드를 `df['item_idx']`라는 새로운 열에 저장하세요. *(사용자 ID 역시 동일한 방식으로 `df['user_idx']`에 저장합니다.)*\n",
    "* **미션 2-2:** 추천 결과로 나온 숫자 인덱스를 다시 영화 제목으로 변환해 확인할 수 있도록, 인덱스와 영화 제목 쌍을 담은 딕셔너리를 생성하세요.\n",
    "\n",
    "```python\n",
    "# [힌트 코드] \n",
    "# 영화 제목과 사용자 ID를 범주형으로 변환하여 0부터 시작하는 인덱스 부여\n",
    "df['movie_title'] = df['movie_title'].astype('category')\n",
    "df['item_idx'] = df['movie_title'].cat.codes\n",
    "\n",
    "df['user_id'] = df['user_id'].astype('category')\n",
    "df['user_idx'] = df['user_id'].cat.codes\n",
    "\n",
    "# 숫자 인덱스를 영화 제목 텍스트로 다시 바꾸기 위한 매핑 딕셔너리 생성\n",
    "idx_to_movie = dict(enumerate(df['movie_title'].cat.categories))\n",
    "\n",
    "```\n",
    "\n",
    "#### 단계 3: 암묵적 피드백 점수 설계 및 CSR 희소 행렬(Sparse Matrix) 생성하기\n",
    "\n",
    "* **미션 3-1: (암묵적 피드백 설계)** 기존의 단순 시청 횟수(`view_count = 1`) 방식에서 벗어나, 데이터셋의 평점(`rating`)이나 시간 정보 등을 활용하여 유저의 선호 강도를 대변할 수 있는 **새로운 암묵적 피드백 점수 컬럼**(`df['implicit_value']`)을 정의하고 생성해 보세요.\n",
    "> *팁:* 평점 자체를 가중치로 쓰거나, 특정 평점 이상(예: 3.5점)인 데이터만 선호(1)로 변환하는 기법, 또는 시간 감쇠를 활용하는 기법 등을 활용해 보세요. 암묵적 피드백을 어떻게 설계하느냐에 따라 모델 성능이 좌우됩니다.\n",
    "\n",
    "\n",
    "* **미션 3-2: (희소 행렬 변환)** 위에서 새로 정의한 `df['implicit_value']`를 값(Data)으로 하고, 변환된 `df['user_idx']`를 행(Row), `df['item_idx']`를 열(Column)로 배치하여 최종 추천 모델 입력에 사용할 CSR 희소 행렬을 생성하세요.\n",
    "\n",
    "* **주의사항:** 변환 후 행렬의 크기(Shape)가 고유 사용자 수(943명)와 고유 영화 수(1,664개)와 정확히 일치하는지 `shape` 속성을 출력하여 반드시 검증하세요.\n",
    "\n",
    "#### 단계 4: ALS 모델 학습 및 개인화 추천 실행\n",
    "\n",
    "* **미션 4-1:** 잠재 요인 수 `factors=20`, 시소 타기 반복 횟수 `iterations=15`로 설정하여 `AlternatingLeastSquares` 모델을 선언하고 전체 데이터를 학습시키세요.\n",
    "* **미션 4-2:** 196번 사용자(user_id=196, 내부 인덱스 기준 195번)의 과거 시청 데이터를 바탕으로, 이 사용자가 아직 보지 않았으나 가장 좋아할 만한 영화 5개를 추천받으세요. 앞서 만든 `idx_to_movie` 딕셔너리를 활용해 결과 화면에는 숫자가 아닌 **영화 제목 텍스트**가 출력되어야 합니다.\n",
    "\n",
    "#### 단계 5: 모델 성능 평가 지표 출력 및 해석하기\n",
    "\n",
    "* **미션 5-1:** 학습을 진행하기 전, 전체 데이터를 `train_test_split` 함수를 활용하여 학습용(Train) 데이터와 검증용(Test) 데이터로 분할하세요. (분할 비율은 학습용 80%로 설정합니다.)\n",
    "* **미션 5-2:** 분할된 학습용 데이터로 ALS 모델을 학습시킨 후, `ranking_metrics_at_k` 함수를 사용하여 상위 10개 추천 기준(`K=10`)의 정밀도(Precision), 평균 정밀도(MAP), 순위 반영 지표(NDCG)를 출력하세요.\n",
    "* **미션 5-3:** 잠재 요인 수 `factors` 변화에 따라 모델의 평가 지표가 어떻게 변하는지 확인해보세요.\n",
    "* **주의사항:** 라이브러리 버전에 따른 오류를 방지하기 위해 딕셔너리의 `.get()` 메서드를 활용하여 안전하게 지표를 가져오도록 코드를 작성하세요. 결과를 확인한 후, 정밀도(Precision) 점수가 비즈니스적으로 어떤 의미를 가지는지 스스로 정리해 보세요.\n",
    "\n",
    "\n",
    "### 16.5.4 [과제 점검 포인트]\n",
    "\n",
    "1. 희소 행렬을 만들 때 행렬의 크기 설정이나 좌표 지정에서 사용자 인덱스와 상품 인덱스의 순서가 뒤바뀌지 않았나요?\n",
    "2. 최종 추천 결과 화면에 기계적인 숫자 코드가 아닌, 실제 영화 제목(예: 'Star Wars (1977)', 'Fargo (1996)')이 올바르게 매핑되어 출력되나요?\n",
    "3. 추천 결과 옆에 출력된 점수(Score)가 5점 만점의 평점이 아니라 '선호 확률 및 경향성'을 뜻한다는 비즈니스적 의미를 설명할 수 있나요?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1b6c689e-4802-405d-85b0-3a9dfe331704",
   "metadata": {},
   "source": [
    "#### 통합 정답 코드"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "6013c738-4a60-4af3-92a5-5f3ba00d72cb",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- MovieLens 실제 데이터 병합 완료 ---\n",
      "전체 데이터 행 개수: 100,000개\n",
      "\n",
      "--- 암묵적 피드백 가중치 반영 완료 ---\n",
      "  user_id                 movie_title  rating  implicit_value\n",
      "0     196                Kolya (1996)       3             1.0\n",
      "1     186    L.A. Confidential (1997)       3             1.0\n",
      "2      22         Heavyweights (1994)       1             1.0\n",
      "3     244  Legends of the Fall (1994)       2             1.0\n",
      "4     166         Jackie Brown (1997)       1             1.0\n",
      "\n",
      "--- CSR 행렬 생성 및 검증 ---\n",
      "실제 고유 사용자 수: 943명 | 고유 영화 수: 1664개\n",
      "생성된 CSR 행렬 크기 (Shape): (943, 1664)\n",
      "-> 검증 성공: 행렬의 크기가 고유 데이터 수와 정확히 일치합니다.\n",
      "\n",
      "--- ALS 모델 학습 시작 ---\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "100%|██████████| 15/15 [00:00<00:00, 125.70it/s]\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- ALS 모델 학습 완료 ---\n",
      "\n",
      "--- [모델 성능 평가 지표 (Top-10)] ---\n",
      "Precision@10 (정밀도) : 0.4180\n",
      "AUC@10       (곡선면적): 0.6149\n",
      "MAP@10       (평균정밀도): 0.2732\n",
      "NDCG@10      (순위반영)  : 0.4155\n",
      "\n",
      "--- [원본 사용자 ID: 196번 맞춤형 영화 추천 결과] ---\n",
      "1위: Postino, Il (1994)                                 | 추천 점수(선호 확률): 0.6622\n",
      "2위: Young Frankenstein (1974)                          | 추천 점수(선호 확률): 0.6155\n",
      "3위: Annie Hall (1977)                                  | 추천 점수(선호 확률): 0.6001\n",
      "4위: Big Night (1996)                                   | 추천 점수(선호 확률): 0.5746\n",
      "5위: Everyone Says I Love You (1996)                    | 추천 점수(선호 확률): 0.5680\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "# 실무 팁: OpenBLAS 스레드 충돌 방지 설정\n",
    "os.environ['OPENBLAS_NUM_THREADS'] = '1'\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "from scipy.sparse import csr_matrix\n",
    "import implicit\n",
    "from implicit.evaluation import train_test_split, ranking_metrics_at_k\n",
    "\n",
    "# ==========================================\n",
    "# 단계 1 ~ 2: 데이터 불러오기 및 병합\n",
    "# ==========================================\n",
    "\n",
    "# 1. 사용자 평점 데이터(u.data) 불러오기\n",
    "data_path = 'ml-100k/u.data'\n",
    "data_cols = ['user_id', 'movie_id', 'rating', 'timestamp']\n",
    "ratings_df = pd.read_csv(data_path, sep='\\t', names=data_cols, encoding='utf-8')\n",
    "\n",
    "# 2. 영화 정보 데이터(u.item) 불러오기\n",
    "item_path = 'ml-100k/u.item'\n",
    "item_cols = ['movie_id', 'movie_title']\n",
    "items_df = pd.read_csv(item_path, sep='|', names=item_cols, usecols=[0, 1], encoding='ISO-8859-1')\n",
    "\n",
    "# 3. 두 데이터프레임을 movie_id 기준으로 병합(Merge)\n",
    "df = pd.merge(ratings_df, items_df, on='movie_id')\n",
    "\n",
    "print(\"--- MovieLens 실제 데이터 병합 완료 ---\")\n",
    "print(f\"전체 데이터 행 개수: {len(df):,}개\")\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 단계 3: 암묵적 피드백 점수 설계 및 CSR 희소 행렬 생성\n",
    "# ==========================================\n",
    "\n",
    "# [미션 3-1] 암묵적 피드백 설계\n",
    "# 단순 시청 여부(1)가 아닌, 사용자의 평점 강도를 신뢰도로 반영하는 예시 산식입니다.\n",
    "# 최소값을 1.0으로 보장하고, 3.5점 이상인 좋은 평점에는 더 큰 가중치(예: 평점 그대로 또는 추가 점수)를 부여\n",
    "df['implicit_value'] = df['rating'].apply(lambda x: x if x >= 3.5 else 1.0)\n",
    "\n",
    "# 고유 사용자 수와 고유 영화 수를 저장 (Shape 검증용)\n",
    "num_users = df['user_id'].nunique()\n",
    "num_items = df['movie_title'].nunique()\n",
    "\n",
    "# 영화 제목과 사용자 ID를 범주형으로 변환하여 0부터 시작하는 인덱스 부여\n",
    "df['movie_title'] = df['movie_title'].astype('category')\n",
    "df['item_idx'] = df['movie_title'].cat.codes\n",
    "\n",
    "df['user_id'] = df['user_id'].astype('category')\n",
    "df['user_idx'] = df['user_id'].cat.codes\n",
    "\n",
    "# 숫자 인덱스를 영화 제목 텍스트로 다시 바꾸기 위한 매핑 딕셔너리 생성\n",
    "idx_to_movie = dict(enumerate(df['movie_title'].cat.categories))\n",
    "\n",
    "print(\"\\n--- 암묵적 피드백 가중치 반영 완료 ---\")\n",
    "print(df[['user_id', 'movie_title', 'rating', 'implicit_value']].head())\n",
    "\n",
    "\n",
    "# [미션 3-2] implicit 라이브러리용 희소 행렬 생성\n",
    "# 값(Value) 자리에 새로 설계한 implicit_value를 적용하여 CSR 행렬을 생성합니다.\n",
    "user_items = csr_matrix(\n",
    "    (df['implicit_value'].astype(float), (df['user_idx'], df['item_idx'])),\n",
    "    shape=(num_users, num_items)\n",
    ")\n",
    "\n",
    "# [주의사항] 행렬의 크기(Shape) 검증 및 고유 수 일치 여부 출력\n",
    "print(\"\\n--- CSR 행렬 생성 및 검증 ---\")\n",
    "print(f\"실제 고유 사용자 수: {num_users}명 | 고유 영화 수: {num_items}개\")\n",
    "print(f\"생성된 CSR 행렬 크기 (Shape): {user_items.shape}\")\n",
    "\n",
    "if user_items.shape == (num_users, num_items):\n",
    "    print(\"-> 검증 성공: 행렬의 크기가 고유 데이터 수와 정확히 일치합니다.\")\n",
    "else:\n",
    "    print(\"-> 검증 실패: 행렬 크기를 다시 확인하세요.\")\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 단계 4 & 단계 5: 데이터 분할, 모델 학습, 성능 평가 및 개인화 추천\n",
    "# ==========================================\n",
    "\n",
    "# 1. 데이터를 학습용(Train)과 검증용(Test)으로 분할 (학습용 80%)\n",
    "train_items, test_items = train_test_split(user_items, train_percentage=0.8, random_state=42)\n",
    "\n",
    "# 2. ALS(Alternating Least Squares) 모델 선언\n",
    "model = implicit.als.AlternatingLeastSquares(\n",
    "    factors=20,          \n",
    "    regularization=0.1,  \n",
    "    iterations=15,       \n",
    "    random_state=42      \n",
    ")\n",
    "\n",
    "print(\"\\n--- ALS 모델 학습 시작 ---\")\n",
    "model.fit(train_items)\n",
    "print(\"--- ALS 모델 학습 완료 ---\\n\")\n",
    "\n",
    "# 3. 모델 성능 평가지표 계산 및 출력 (상위 10개 추천 기준: K=10)\n",
    "metrics = ranking_metrics_at_k(model, train_items, test_items, K=10, show_progress=False)\n",
    "\n",
    "print(\"--- [모델 성능 평가 지표 (Top-10)] ---\")\n",
    "print(f\"Precision@10 (정밀도) : {metrics.get('precision', 0):.4f}\")\n",
    "print(f\"AUC@10       (곡선면적): {metrics.get('auc', 0):.4f}\")\n",
    "print(f\"MAP@10       (평균정밀도): {metrics.get('map', 0):.4f}\")\n",
    "print(f\"NDCG@10      (순위반영)  : {metrics.get('ndcg', 0):.4f}\\n\")\n",
    "\n",
    "\n",
    "# 4. 196번 사용자를 위한 영화 5개 추천\n",
    "target_user_id = 196\n",
    "target_user_idx = df[df['user_id'] == target_user_id]['user_idx'].iloc[0]\n",
    "\n",
    "recommendations, scores = model.recommend(\n",
    "    userid=target_user_idx,\n",
    "    user_items=user_items[target_user_idx],\n",
    "    N=5\n",
    ")\n",
    "\n",
    "# 결과 출력\n",
    "print(f\"--- [원본 사용자 ID: {target_user_id}번 맞춤형 영화 추천 결과] ---\")\n",
    "for i, (item_idx, score) in enumerate(zip(recommendations, scores), 1):\n",
    "    movie_title = idx_to_movie[item_idx]\n",
    "    print(f\"{i}위: {movie_title:<50} | 추천 점수(선호 확률): {score:.4f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "322c00af-ca53-4b76-bbbd-081cd7006a34",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "--- MovieLens 실제 데이터 병합 완료 ---\n",
      "전체 데이터 행 개수: 100,000개\n",
      "\n",
      "--- [factors 값 변화에 따른 실험 시작] ---\n",
      "Factors 10 학습 및 평가 완료\n",
      "Factors 20 학습 및 평가 완료\n",
      "Factors 30 학습 및 평가 완료\n",
      "Factors 40 학습 및 평가 완료\n",
      "Factors 50 학습 및 평가 완료\n",
      "Factors 100 학습 및 평가 완료\n",
      "Factors 150 학습 및 평가 완료\n",
      "\n",
      "=== 최종 실험 결과 비교 ===\n",
      " Factors Precision@10 AUC@10 MAP@10 NDCG@10\n",
      "      10       0.4121 0.6112 0.2733  0.4124\n",
      "      20       0.4180 0.6149 0.2732  0.4155\n",
      "      30       0.3986 0.6114 0.2550  0.3968\n",
      "      40       0.3809 0.6067 0.2424  0.3819\n",
      "      50       0.3679 0.6045 0.2293  0.3684\n",
      "     100       0.2990 0.5885 0.1737  0.3025\n",
      "     150       0.2445 0.5720 0.1309  0.2455\n"
     ]
    }
   ],
   "source": [
    "import os\n",
    "# 실무 팁: OpenBLAS 스레드 충돌 방지 설정\n",
    "os.environ['OPENBLAS_NUM_THREADS'] = '1'\n",
    "\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "from scipy.sparse import csr_matrix\n",
    "import implicit\n",
    "from implicit.evaluation import train_test_split, ranking_metrics_at_k\n",
    "\n",
    "# ==========================================\n",
    "# 단계 1 ~ 2: 데이터 불러오기 및 병합\n",
    "# ==========================================\n",
    "\n",
    "# 1. 사용자 평점 데이터(u.data) 불러오기\n",
    "data_path = 'ml-100k/u.data'\n",
    "data_cols = ['user_id', 'movie_id', 'rating', 'timestamp']\n",
    "ratings_df = pd.read_csv(data_path, sep='\\t', names=data_cols, encoding='utf-8')\n",
    "\n",
    "# 2. 영화 정보 데이터(u.item) 불러오기\n",
    "item_path = 'ml-100k/u.item'\n",
    "item_cols = ['movie_id', 'movie_title']\n",
    "items_df = pd.read_csv(item_path, sep='|', names=item_cols, usecols=[0, 1], encoding='ISO-8859-1')\n",
    "\n",
    "# 3. 두 데이터프레임을 movie_id 기준으로 병합(Merge)\n",
    "df = pd.merge(ratings_df, items_df, on='movie_id')\n",
    "\n",
    "print(\"--- MovieLens 실제 데이터 병합 완료 ---\")\n",
    "print(f\"전체 데이터 행 개수: {len(df):,}개\")\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 단계 3: 암묵적 피드백 점수 설계 및 CSR 희소 행렬 생성\n",
    "# ==========================================\n",
    "\n",
    "# [미션 3-1] 암묵적 피드백 설계\n",
    "df['implicit_value'] = df['rating'].apply(lambda x: x if x >= 3.5 else 1.0)\n",
    "\n",
    "# 고유 사용자 수와 고유 영화 수를 저장\n",
    "num_users = df['user_id'].nunique()\n",
    "num_items = df['movie_title'].nunique()\n",
    "\n",
    "# 영화 제목과 사용자 ID를 범주형으로 변환하여 인덱스 부여\n",
    "df['movie_title'] = df['movie_title'].astype('category')\n",
    "df['item_idx'] = df['movie_title'].cat.codes\n",
    "\n",
    "df['user_id'] = df['user_id'].astype('category')\n",
    "df['user_idx'] = df['user_id'].cat.codes\n",
    "\n",
    "# [미션 3-2] implicit 라이브러리용 희소 행렬 생성\n",
    "user_items = csr_matrix(\n",
    "    (df['implicit_value'].astype(float), (df['user_idx'], df['item_idx'])),\n",
    "    shape=(num_users, num_items)\n",
    ")\n",
    "\n",
    "\n",
    "# ==========================================\n",
    "# 단계 4: 데이터 분할 및 factors 변화에 따른 성능 평가\n",
    "# ==========================================\n",
    "\n",
    "# 1. 데이터를 학습용(Train)과 검증용(Test)으로 분할 (학습용 80%)\n",
    "train_items, test_items = train_test_split(user_items, train_percentage=0.8, random_state=42)\n",
    "\n",
    "# 2. 테스트할 factors 후보군 리스트 정의\n",
    "factors_candidates = [10, 20, 30, 40, 50, 100, 150]\n",
    "results = []\n",
    "\n",
    "print(\"\\n--- [factors 값 변화에 따른 실험 시작] ---\")\n",
    "\n",
    "for f_val in factors_candidates:\n",
    "    # 각 factors 값으로 모델 선언\n",
    "    model = implicit.als.AlternatingLeastSquares(\n",
    "        factors=f_val,          \n",
    "        regularization=0.1,  \n",
    "        iterations=15,       \n",
    "        random_state=42      \n",
    "    )\n",
    "    \n",
    "    # 모델 학습 (진행 바 제외)\n",
    "    model.fit(train_items, show_progress=False)\n",
    "    \n",
    "    # 모델 성능 평가 지표 계산 (K=10)\n",
    "    metrics = ranking_metrics_at_k(model, train_items, test_items, K=10, show_progress=False)\n",
    "    \n",
    "    # 결과 저장\n",
    "    results.append({\n",
    "        'Factors': f_val,\n",
    "        'Precision@10': metrics.get('precision', 0),\n",
    "        'AUC@10': metrics.get('auc', 0),\n",
    "        'MAP@10': metrics.get('map', 0),\n",
    "        'NDCG@10': metrics.get('ndcg', 0)\n",
    "    })\n",
    "    print(f\"Factors {f_val} 학습 및 평가 완료\")\n",
    "\n",
    "# 3. 결과를 데이터프레임으로 변환하여 출력\n",
    "results_df = pd.DataFrame(results)\n",
    "print(\"\\n=== 최종 실험 결과 비교 ===\")\n",
    "print(results_df.to_string(index=False, formatters={\n",
    "    'Precision@10': '{:.4f}'.format,\n",
    "    'AUC@10': '{:.4f}'.format,\n",
    "    'MAP@10': '{:.4f}'.format,\n",
    "    'NDCG@10': '{:.4f}'.format\n",
    "}))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9d3c02fc-3194-4dce-b1cb-7393993753ca",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.15"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
