TMDB の映画データから異種グラフ(HeteroData)を構築する
本記事では、TMDB API を用いて映画データを収集し、それを PyTorch Geometric の HeteroData 形式へ変換する方法についてまとめます。
コードを細かく解説する記事ではなく、
- どうやってデータを取得したか
- どんな項目を収集したか
- どのようなノードとエッジを持つ異種グラフになったか
を中心に記述します。
1. 取得したデータの種類
TMDB API の /discover/movie を使用して人気映画を 500 件取得し、
さらに /movie/{id}?append_to_response=credits で映画の詳細を取得しました。
収集したデータは次の 4 つの TSV に保存しています。
(1) movies.tsv
映画ごとに次の情報を取得しています:
- movie_id
- title
- genres(ジャンル名)
- genre_ids(ジャンル ID)
- vote_average
- popularity
- budget
- revenue
- release_date
- overview
追加で収集したフィールド:
- production_companies
- production_countries
- spoken_languages
- collection_name
- keyword_names / keyword_ids
- similar_movie_ids
- recommended_movie_ids
(2) movie_cast.tsv
映画に出演した俳優リスト:
- movie_id
- person_id
- name
- character
- order(出演順序)
(3) movie_crew.tsv
制作スタッフ情報:
- movie_id
- person_id
- name
- job
- department
俳優ほど重要度が低いため、詳細な API 取得は行っていません。
(4) persons.tsv
俳優の詳細情報:
- person_id
- name
- known_for_department
- gender
- biography
- birthday
- deathday
- place_of_birth
- also_known_as
- popularity
- profile_path
2. HeteroData のノード構造
収集したデータを以下の 5 種類のノードに変換しました。
| ノード | 内容 |
|---|---|
movie |
映画作品 |
person |
俳優 |
genre |
ジャンル |
department |
俳優の部署(Acting, Directing など) |
gender |
性別区分 |
3. HeteroData のエッジ構造
| Edge | 説明 |
|---|---|
(person, acts_in, movie) |
俳優が出演した映画 |
(movie, in_genre, genre) |
映画のジャンル |
(person, has_department, department) |
俳優の活動領域 |
(person, has_gender, gender) |
性別 |
| ※ optional | similar / recommended(今回は未使用) |
4. HeteroData の中身(print 結果)
以下に、実際に print(data) した結果をそのまま貼ります。
--- Node type: movie ---
id: Tensor (265,) torch.int64
title: list/dict len=265 (type=list)
overview: list/dict len=265 (type=list)
release_date: list/dict len=265 (type=list)
vote_average: Tensor (265,) torch.float32
popularity: Tensor (265,) torch.float32
budget: Tensor (265,) torch.float32
revenue: Tensor (265,) torch.float32
genre_ids: list/dict len=265 (type=list)
genre_names: list/dict len=265 (type=list)
production_companies: list/dict len=265 (type=list)
production_countries: list/dict len=265 (type=list)
spoken_languages: list/dict len=265 (type=list)
keyword_ids: list/dict len=265 (type=list)
keyword_names: list/dict len=265 (type=list)
similar_movie_ids: list/dict len=265 (type=list)
recommended_movie_ids: list/dict len=265 (type=list)
--- Node type: person ---
id: Tensor (9524,) torch.int64
name: list/dict len=9524 (type=list)
biography: list/dict len=9524 (type=list)
birthday: list/dict len=9524 (type=list)
deathday: list/dict len=9524 (type=list)
place_of_birth: list/dict len=9524 (type=list)
also_known_as: list/dict len=9524 (type=list)
profile_path: list/dict len=9524 (type=list)
popularity: Tensor (9524,) torch.float32
department_idx: Tensor (9524,) torch.int64
gender_idx: Tensor (9524,) torch.int64
known_for_department: list/dict len=9524 (type=list)
gender_raw: list/dict len=9524 (type=list)
--- Node type: genre ---
id: Tensor (18,) torch.int64
name: list/dict len=18 (type=list)
id2idx: list/dict len=18 (type=dict)
--- Node type: keyword ---
id: Tensor (1779,) torch.int64
name: list/dict len=1779 (type=list)
id2idx: list/dict len=1779 (type=dict)
--- Node type: company ---
name: list/dict len=551 (type=list)
name2idx: list/dict len=551 (type=dict)
--- Node type: country ---
name: list/dict len=37 (type=list)
name2idx: list/dict len=37 (type=dict)
--- Node type: language ---
name: list/dict len=45 (type=list)
name2idx: list/dict len=45 (type=dict)
--- Node type: department ---
name: list/dict len=12 (type=list)
name2idx: list/dict len=12 (type=dict)
--- Node type: gender ---
name: list/dict len=4 (type=list)
name2idx: list/dict len=4 (type=dict)
==================== EDGE DETAILS ==========================
--- Edge: ('person', 'acts_in', 'movie') ---
edge_index: shape=(2, 10916)
--- Edge: ('person', 'directs', 'movie') ---
edge_index: shape=(2, 69)
--- Edge: ('movie', 'in_genre', 'genre') ---
edge_index: shape=(2, 726)
--- Edge: ('movie', 'produced_by', 'company') ---
edge_index: shape=(2, 794)
--- Edge: ('movie', 'produced_in', 'country') ---
edge_index: shape=(2, 356)
--- Edge: ('movie', 'spoken_in', 'language') ---
edge_index: shape=(2, 430)
--- Edge: ('movie', 'has_keyword', 'keyword') ---
edge_index: shape=(2, 3125)
※ PyTorch Geometric の HeteroData は、ノード・エッジ種類ごとに
x, id, name, edge_index などを保持します。
5. 自己教師あり学習:俳優とジャンルを同じ埋め込み空間へ
今回の目的は「新しいラベルを予測する分類」ではなく、異種グラフの構造そのものから埋め込み表現を学習することです。
具体的には、以下のような直感を満たす埋め込みを得たいです。
- ある俳優が出演する映画のジャンルに偏りがあるなら、その俳優はそのジャンルに近い場所へ埋め込まれる
- 俳優が出演した映画を介して、俳優とジャンルが同じ空間で比較できるようにする
- エッジでつながっているノードは近く、つながっていないノードは遠くなる
このために、**グラフのメッセージパッシング(GNN)**と、**Siamese Network 風の対照学習(contrastive learning)**を組み合わせた学習を行いました。
6. モデル:Person–Movie–Genre を双方向に伝播する Hetero GNN
6.1 モデルの考え方
今回のグラフの主な構造は次の 2 つです:
person --acts_in--> moviemovie --in_genre--> genre
これらを 双方向に伝播できるようにすることで、
- person ↔ movie ↔ genre の情報が相互に混ざる
- person と genre が 同一の埋め込み空間に揃う
という状態を目指します。
6.2 実装方針
-
data["*"].xはまだ特徴量サイズが決まっていないため、学習開始時は使いません - 各ノードタイプごとに
nn.Embedding(num_nodes, hidden_dim)を持ち、そこから学習を開始します -
HeteroConv + GraphConvをnum_layers=2回積み重ねます - 学習後、
model(data)が返すx_dict["person"]とx_dict["genre"]を同一空間として扱います
7. 学習方法:Siamese / Contrastive Loss による自己教師学習
7.1 何を「正例」「負例」とするか
グラフにおいて「エッジが存在する関係」を正例とし、ランダムに選んだ別ノードを負例とします。
-
正例(positive)
- person — acts_in → movie の実エッジ
(p, m_pos) - movie — in_genre → genre の実エッジ
(m, g_pos)
- person — acts_in → movie の実エッジ
-
負例(negative)
- person に対して、ランダムな movie
(p, m_neg) - movie に対して、ランダムな genre
(m, g_neg)
- person に対して、ランダムな movie
負例が正例と一致する可能性(衝突)はありますが、今回は「シンプルさ優先」で許容しています
(衝突回避を入れるとコードも計算も重くなるため)。
7.2 Siamese Network 風の損失関数
(src, pos, neg) という 3 つのベクトルがあるとき、
src が pos に近く、neg から遠くなるように学習します。
今回は cosine similarity を使い、2 クラス分類の cross entropy に落とします:
-
sim(src, pos)を「正解クラスのスコア」 -
sim(src, neg)を「負例クラスのスコア」
として、
- 正例の方を大きく
- 負例の方を小さく
なるように学習します。
8. 学習コード(全体像)
学習は以下のように行います。
-
model(data)で埋め込みを得る -
compute_loss()で person–movie, movie–genre の対照損失を計算 - Adam で更新
(※ ここでは「記事としての説明」が主なので詳細コードは省略し、後でリポジトリに整理する想定です)
9. 学習結果:俳優とジャンルが同一空間に配置される
9.1 可視化(t-SNE / PCA)
学習後の埋め込み x_dict から:
-
x_dict["person"](俳優埋め込み) -
x_dict["genre"](ジャンル埋め込み)
を取り出し、t-SNEで 2 次元に落としてプロットしました。
- 青点:俳優(人数が多いのでサンプリング)
- 赤 ×:ジャンル(ラベル付き)
このプロットにより、
- ジャンル同士が適度に離れ
- 俳優が特定ジャンル付近に固まる
という「直感的に妥当な構造」が確認できました。
9.2 人物名から「近いジャンル」を表示する
プロットだけでは「なんとなくそれっぽい」で終わってしまうので、
人物名を入力すると、その人物が近いジャンル Top-K を出す仕組みも追加しました。
やっていることはシンプルで:
-
person_nameから person ノードの index を探す - その person embedding と全 genre embedding の cosine similarity を計算
- 類似度 top-k のジャンルを表示
これにより、
- 「この俳優はどのジャンル寄りか?」
- 「この俳優は意外と〇〇ジャンルにも近い?」
といった 埋め込みの解釈が可能になりました。
補足:Leonardo DiCaprio のジャンル類似度結果(埋め込みの解釈例)
学習済みの Person–Genre 埋め込み空間を用いて、
Leonardo DiCaprio に最も近いジャンルを cosine similarity により算出しました。
Leonardo DiCaprio (appearances=5)
Crime score=0.1342
Drama score=0.0955
Adventure score=0.0600
Romance score=0.0488
Action score=0.0377
Family score=0.0182
Comedy score=0.0164
Animation score=0.0105
Thriller score=0.0053
Fantasy score=0.0000
-
Crime / Drama が最上位
→ 『The Departed』『Catch Me If You Can』『The Wolf of Wall Street』など、
犯罪・ドラマ文脈の作品群と強く結びついていることを反映。 -
Adventure / Romance / Action が中位
→ 『Titanic』『The Revenant』『Inception』など、
主ジャンル以外の要素も一定程度含まれていることが分かる。 -
Animation / Fantasy がほぼ 0
→ 声優出演やファンタジー色の強い作品への関与が少ないため、
埋め込み空間でも距離が離れている。
ポイント
- この結果は 出演回数の単純集計ではない
- 映画ノードを介した構造(person → movie → genre)を通じて、
ジャンル文脈への近さが学習されている - 人手でラベルを与えなくても、
「この俳優はどんなジャンル寄りか?」が数値として解釈可能
このように、自己教師ありで学習した埋め込みでも、
俳優のキャリア傾向を直感的かつ定量的に捉えられることが確認できました。
10. まとめ
本記事では、
- TMDB API を使って映画データを TSV として収集
- それを
HeteroDataに変換して異種グラフを構築 - person–movie–genre を中心としたグラフを GNN で双方向伝播
- Siamese / contrastive 学習によって俳優とジャンルを同一空間へ埋め込み
- 可視化&人物名からのジャンル類似度検索を可能にした
という流れをまとめました。
11. 今後の拡張(アイデア)
今回の構造は person–movie–genre が中心でしたが、すでに以下のノードも入っています:
- company / country / language / keyword / department / gender
このため、同じ枠組みで次のような拡張ができます。
- 俳優 → keyword 類似度(どんなテーマの作品に出がちか)
- 俳優 → country / language(活動圏の傾向)
- 映画 → 映画 類似度(ジャンル+出演者+制作会社ベース)
- 俳優 ↔ 俳優 類似度(共演傾向)
「メタデータ検索」ではなく、グラフ構造から意味を発見する検索にできるのが面白い点です。
