Notice
Recent Posts
Recent Comments
Link
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

Elevation

Chess Opening Recommendation with NeuMF model (2) 본문

ML/study

Chess Opening Recommendation with NeuMF model (2)

aste999 2026. 5. 26. 02:01

 

학습 데이터 가공

기본적인 토대를 짠 이후에 추천 시스템에 대해 생각해 보았다. 고전적인 ML 기반의 추천 시스템은 크게 2가지로 나뉜다.

 

  • Content-based Filtering: 사용자의 과거 선호 아이템들을 분석해, 유사도가 높은 다른 아이템 추천.
  • Collaborative Filtering: 사용자와 성향이 비슷한 다른 사용자들이 선호한 아이템 추천.

 

Content-based Filtering은 새로운 성향의 아이템을 추천해 주기 어렵다는 문제가 있기 때문에, 트리 구조를 갖는 체스 오프닝의 경우에는 기존 오프닝의 상위/하위 오프닝들만 추천해 줄 가능성이 높다고 생각해 Collaborative Filtering을 사용하기로 결정했다. Collaborative Filtering의 가장 큰 문제는 cold start이다. 즉 충분히 많은 수의 user-item interaction 히스토리가 필요한데, 이는 lichess API를 사용해 사전에 데이터를 확보해 둠으로써 손쉽게 해결이 가능하다고 생각했다. 구체적으로는, 랜덤 상위권 유저 5명 가량을 시드로 하여 그들의 대결 상대를 검색하는 방식으로 상위권(elo 2000+) 유저 약 1000명의 목록을 확보했고, 그들의 최근 대국 기록을 수집하여 user-item matrix를 구축하는 데 성공하였다.

 

영화 평점에 해당하는 interaction score를 어떻게 계산할 것인지에 대해서도 고민이 있었다. 선택률을 기본으로 하되 승률도 반영하면 좋겠다는 생각이 있었다. 따라서 (실제 오프닝 선택률) / (해당 오프닝의 평균 선택률)로 구한 상대 선택률에 log를 취하고, score%를 곱해서 최종 계산식을 완성했다. 다만 아예 선택하지 않은 경우와는 구분할 필요가 있으므로 이후 $[0.2, 1.0]$ 구간으로 min-max scaling을 해 주고, 랜덤으로 negative sampling을 하여 추천하지 않아야 하는 항목도 학습하도록 했다.

 

# user_features.csv
user_features.append({
    "user_id": username,
    "popularity": style["popularity"],
    "sharpness": style["sharpness"],
    "games_count": sum(opt["count"] for color in stats for opt in stats[color].values())
})

# interactions.csv
for color in ["white", "black"]:
    search_df = df_white if color=="white" else df_black
    for op_id, opt in stats[color].items():

        if opt["count"] >= 3:
            win_rate = opt["score_sum"] / opt["count"]
            sr = search_df[search_df["id"] == op_id].iloc[0]["selection_rate"]
            score = np.log1p(opt["count"] / (sr + 0.0001)) * (win_rate + 0.5)

            interactions.append({
                "user_id": username,
                "opening_id": op_id,
                "color": color,
                "play_count": opt["count"],
                "win_rate": win_rate,
                "interaction_score": score
            })

 

 

Neural Collaborative Filtering (2017)

이제 알고리즘을 선정할 차례가 되었다. Collaborative Filtering의 고전적 방법론은 다시 user-based나 item-based로 유사도를 측정하여 추천하는 memory based approach와, truncated SVD의 원리를 이용한 matrix factorization으로 추천하는 model based approach로 나뉜다. Matrix Factorization은 아래 사진과 같이 latent vector를 학습하여 데이터에 없는 interaction의 결과를 예측하는 방식이다.

 

https://afafathar.medium.com/building-advanced-recommendation-systems-matrix-factorization-and-deep-learning-19dabbc1f4ed

 

그러나 체스 오프닝은 종류가 많고 선형적 관계로 표현되기 어렵다는 문제가 있으며, 또한 이전에 추출한 오프닝 insight가 있으므로 이 데이터를 사용하여 보다 고성능의 추천 시스템을 고안할 필요성을 느꼈다. 이를 위해 사용한 모델이 MF를 발전시킨 형태인  NCF(neural collaborative filtering)이다.

 

NCF는 matrix factorization에 가중치를 둬서 일반화한 형태인 gmf(generalized mf)와, 심층신경망으로 interaction의 비선형적 관계를 포착하는 mlp(multi-layer perceptron)로 구성되어 있다. 간단히 과정을 설명하면 다음과 같다.

 

  • gmf와 mlp를 위한 user embedding vector, item embedding vector를 각각 준비한다.
  • 기존 mf처럼 latent vector를 활용한 예측은 $\hat{y} _{u,i} = P_u^T Q_i$, 즉 두 latent vector의 내적으로 나타낼 수 있으므로 gmf layer에서 내적을 수행한다.
  • mlp layer에서는 단순히 두 latent vector를 concat한 뒤 심층신경망에 통과시킨다.
  • gmf layer, mlp layer의 결과를 concat하여 linear layer에 통과시키는 방식으로 두 모델을 앙상블한다.

 

https://arxiv.org/abs/1708.05031

 

Neural Collaborative Filtering

In recent years, deep neural networks have yielded immense success on speech recognition, computer vision and natural language processing. However, the exploration of deep neural networks on recommender systems has received relatively less scrutiny. In thi

arxiv.org

mlp layer에서는 user와 item latent vector를 concat하여 입력으로 사용하므로, 여기에 user의 insight 지표([sharpness, popularity]), item의 insight 지표도 추가하는 방식으로 모델을 커스텀했다. 논문 poc에서는 one-hot encoding을 사용했지만, encoder 형태로 embedding vector를 입력받는 방식도 상관없다고 언급된 바 있기에 user의 경우에는 적당히 encoder를 만들어서 사용했다. 대략적인 모델의 구조는 아래와 같다.

 

class ChessNCF(nn.Module):
    def __init__(self, num_items, style_dim=2, factor_num=32, alpha=0.6):
        super(ChessNCF, self).__init__()

        self.user_encoder_GMF = nn.Sequential(
            nn.Linear(num_items, 128),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(128, factor_num)
        )
        self.user_encoder_MLP = nn.Sequential(
            nn.Linear(num_items, 128),
            nn.BatchNorm1d(128),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(128, factor_num)
        )
        
        self.embed_item_GMF = nn.Embedding(num_items, factor_num)
        self.embed_item_MLP = nn.Embedding(num_items, factor_num)
        
        mlp_input_dim = (factor_num * 2) + (style_dim * 2)
        self.mlp_layers = nn.Sequential(
            nn.Linear(mlp_input_dim, 64, bias=False),
            nn.BatchNorm1d(64),
            nn.ReLU(),
            nn.Linear(64, 16, bias=False),
            nn.BatchNorm1d(16),
            nn.ReLU(),
        )
        self.prediction_layer = nn.Linear(factor_num + 16, 1)
        self.sigmoid = nn.Sigmoid()
        self.alpha = alpha

    def forward(self, user_hist, item_indices, user_style, item_style):
        u_gmf = self.user_encoder_GMF(user_hist)
        u_mlp = self.user_encoder_MLP(user_hist)

        i_gmf = self.embed_item_GMF(item_indices)
        i_mlp = self.embed_item_MLP(item_indices)

        # GMF Path
        gmf_output = u_gmf * i_gmf
        
        # MLP Path
        mlp_input = torch.cat([u_mlp, i_mlp, user_style, item_style], dim=-1)
        mlp_output = self.mlp_layers(mlp_input)
        
        # concat
        combined = torch.cat([self.alpha*gmf_output, (1-self.alpha)*mlp_output], dim=-1)
        prediction = self.prediction_layer(combined)
        return self.sigmoid(prediction).view(-1)

 

 

학습 및 평가

학습 데이터와 모델이 모두 준비되었으니, train_test_split으로 분리한 학습/검증용 데이터를 각각 dataloader에 담아 주고 학습을 진행한다. 손실함수로는 MSELoss를 사용했고 optimizer로는 adam를 사용했다. 오버피팅이 나길래 dropout 및 batch 정규화를 적당히 추가해 주고, validation loss가 납득 가능한 수준으로 떨어지는 것을 확인한 뒤 평가를 진행했다.

 

추천 시스템의 대표적인 평가 지표로는 HR(Hit rate), NDCG(Normalized discounted cumulative gain) 등이 있다. HR@10을 측정해 보니 93% 정도로 꽤 준수한 성능을 보였다. 모델 성능 개선을 위해 조금 더 최적화를 시도해 볼 수도 있었겠지만, 첫 프로젝트이기도 하고 슬슬 귀찮아져서 이 정도로 마무리하기로 하였다.

 

 

프론트 ui/ux

프론트의 경우에는 대부분 gemini가 짰기 때문에 크게 할 말은 없다. 그래도 vite를 통한 빌드 및 tailwindcss 사용법을 배웠는데, 웹 개발에 확실히 편리하다는 인상을 받아서 앞으로도 많이 활용하고자 한다. useEffect를 이용해 로딩 처리를 하는 코드도 스타일리시해서 마음에 들었다.

 

const API_URL = import.meta.env.VITE_API_URL;

useEffect(() => {
    if (!username) return;
    const fetchUserData = async () => {
      setLoading(true);
      try {
        const lichessUrl = `https://lichess.org/api/games/user/${username}?max=200&rated=true&perfType=bullet,blitz,rapid,classical`;
        const res = await fetch(lichessUrl, { headers: { 'Accept': 'application/x-ndjson' } });
        if (!res.ok) throw new Error("Lichess API access failed");

        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let { value, done } = await reader.read();
        let leftover = "";
        const games = [];
        while (!done) {
          const chunk = leftover + decoder.decode(value, { stream: true });
          const lines = chunk.split("\n");
          leftover = lines.pop();
          for (const line of lines) {
            if (line.trim()) games.push(JSON.parse(line));
          }
          ({ value, done } = await reader.read());
        }

        const response = await axios.post(`${API_URL}/api/chess/analyze`, {
          username: username,
          games: games,
        });
        setData(response.data.opening_result);
        // ...
      } catch (error) {
        console.error("Analysis error:", error);
      } finally {
        setLoading(false);
      }
    };
    fetchUserData();
}, [username, API_URL]);

if (loading) return (
    // loading msg
);

 

 

유저 친화적 디자인을 위해 다양한 시각화 방법을 시도해 봤는데, 어느 정도 만족스럽게 나온 것 같다. 다만 통계 분석 위주의 기능을 제공하므로 조금 더 정보가 집약적으로 제시되면 좋을 것 같다. 프론트 작성은 확실히 ai의 효율이 극대화되는 포인트라고 생각된다. 경험적으로 여러 번에 걸쳐서 수정하는 것보다는, 기획을 미리 해 놓고 상세하게 프롬프트를 짜서 주문하면 더 만족스러운 결과물이 나오는 것 같다.

 

 

'ML > study' 카테고리의 다른 글

Chess Opening Recommendation with NeuMF model (1)  (0) 2026.05.23