UMAPとDataMapPlotでつくるAAAIランドスケープマップ

本記事は「 [TTDC Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ttdc) 」3 日目の記事です。

# 概要
先日のブログ記事（[AAAI学会(2020~2024)の研究トレンドを分析してみる](https://qiita.com/anyai_corp/items/ec6ba9393faf90008f68)）の続編として、  
AAAI学会のランドスケープマップを作ってみます。

最終的にはこんな感じでグリグリ動かせるマップが出来上がります↓

![landscape.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/acc79e6d-45cd-4a72-9e2b-dfc4c4109eb4.gif)


**作成手順**
- 論文タイトルをLanguage modelでEmbedding (768次元)
- UMAPで次元圧縮 (2次元)
- クラスタリング、トピック導出、成長度算出
- DataMapPlotでインタラクティブに可視化



# 検証条件
データの可視化などを勘案し、Jupyter Notebook (*.ipynbファイル)形式で実施します。

- 環境
  - python: 3.10
  - CUDA: 12.2
  - GPU : NVIDIA A6000 x 1
- ライブラリ
  - inflection==0.5.1
  - pandas==2.2.2
  - requests==2.32.3
  - scipy==1.14.0
  - spacy==3.7.5
  - tqdm==4.66.4
  - matplotlib==3.9.0
  - ipykernel==6.29.4
  - umap-learn==0.5.7
  - datamapplot==0.4.2
  - fast-hdbscan==0.2.0
  - torch==2.5.1
  - transformers==4.45.2
  - adapters==1.0.1
  - huggingface-hub==0.25.2
  - faiss-cpu==1.9.0

# 事前準備
spaCyで形態素解析するので、ターミナルで下記コマンドを実行して英語用辞書をダウンロードしておきます ([参考](https://spacy.io/models/en#en_core_web_sm))。
```python
python -m spacy download en_core_web_sm
```

# データ準備
dblp APIにてAAAI学会の論文タイトルを取得します。  
取得手順は前回記事「[AAAI学会(2020~2024)の研究トレンドを分析してみる：データの用意](https://qiita.com/anyai_corp/items/ec6ba9393faf90008f68#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E7%94%A8%E6%84%8F)」をご参照ください。

上記手順により、2020~2024年のAAAI学会論文タイトル 10,046件 を取得できます。
![img_df.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/d1fe497d-e9aa-2227-f012-2b71ed0a8018.png)


:::note  
以降の実装は、取得結果を`aaai_paper_info.csv` というcsvファイルに保存したと仮定してます  
:::

# Import、設定値
```python
import os
import re
from collections import Counter, defaultdict

import datamapplot
import faiss
import fast_hdbscan
import inflection
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import spacy
import torch
import umap
from adapters import AutoAdapterModel
from matplotlib.colors import to_rgb
from scipy.stats import linregress
from sklearn.cluster import KMeans
from sklearn.feature_extraction.text import TfidfVectorizer
from tqdm.auto import tqdm
from tqdm.autonotebook import trange
from transformers import AutoTokenizer

# 共通設定値
DATA_PATH = "./aaai_paper_info.csv"  # csvファイルpath
DEVICE = "cuda"  # 推論デバイス("cuda" or "cpu")
RANDOM_SEED = 28  # ランダムシード値
EMBEDDING_MODEL = "allenai/specter2_base"  # Embeddingに使用するbackbornモデル名
# UMAPパラメータ
UMAP_PARAMS = {
    "n_neighbors": 15,
    "min_dist": 0.0,
    "metric": "euclidean",
    "random_state": RANDOM_SEED,
}
# HDBSCANパラメータ
HDBSCAN_PARAMS = {"min_cluster_size": 15}
# KNNパラメータ
KNN_PARAMS = {"k": 5}
CLUSTERING_MAX_NUM = 50  # クラスタリング数
GROWTH_CALC_YEARS = [2023, 2024]  # 成長度の算出範囲
```

# 論文タイトルのEmbedding
Embedding用のモデルは、科学系のドキュメントで学習された `allenai/specter2`を使用します。

https://allenai.org/blog/specter2-adapting-scientific-document-embeddings-to-multiple-fields-and-task-formats-c95686c06567

```python
# ref: https://huggingface.co/allenai/specter2
# load model and tokenizer
tokenizer = AutoTokenizer.from_pretrained(EMBEDDING_MODEL)
#load base model
model = AutoAdapterModel.from_pretrained(EMBEDDING_MODEL)
#load the adapter(s) as per the required task, provide an identifier for the adapter in load_as argument and activate it
model.load_adapter("allenai/specter2", source="hf", load_as="proximity", set_active=True)
_ = model.to(DEVICE)
```

```python
# 文章->Embeddig用の関数
def get_sentence_embedding(
    sentences: list[str],
    batch_size: int = 32,
    max_length: int | None = None
) -> np.ndarray:
    """文章をベクトルに変換

    Args:
        sentences (list[str]): ベクトル化する文章.
        batch_size (int, optional): Batchサイズ. Defaults to 32.
        max_length (int | None, optional): モデルへ入力するtoken数の最大値. Defaults to None.

    Returns:
        np.ndarray: 各文章のベクトル情報
    """
    all_embeddings = []
    if isinstance(sentences, str):
        sentences = [sentences]
    length_sorted_idx = np.argsort([-len(str(sen)) for sen in sentences])
    sentences_sorted = [sentences[idx] for idx in length_sorted_idx]

    for start_index in trange(
        0, len(sentences), batch_size, desc="Batches"
    ):
        batch = sentences_sorted[start_index : start_index + batch_size]
        features = tokenizer.batch_encode_plus(
            batch, padding="longest", truncation=True, max_length=max_length, return_tensors="pt"
        ).to(DEVICE)
        with torch.no_grad():
            output = model(**features)
        # ref: https://huggingface.co/allenai/specter2
        output = output["last_hidden_state"]
        output = output[:, 0, :]  # get [CLS] token
        output = output.cpu()
        all_embeddings.extend(output)

    all_embeddings = [all_embeddings[idx] for idx in np.argsort(length_sorted_idx)]
    all_embeddings = torch.stack(all_embeddings, dim=0)
    all_embeddings = all_embeddings.numpy()
    return all_embeddings


paper_df = pd.read_csv(DATA_PATH)
paper_embeddings = get_sentence_embedding(paper_df["title"].to_numpy(), max_length=512)
paper_embeddings.shape

>>> (10046, 768)
```
文章を768次元のベクトルに変換できました。

# 次元圧縮

文章Embedding結果 768次元のベクトルを、可視化しやすいよう2次元に圧縮します。  
次元圧縮手法は TSNEやUMAP、PCAなどがありますが、後のクラスタリングしやすさを考慮しUMAPを使用します。
```python
paper_embeddings_2d = umap.UMAP(
    **UMAP_PARAMS,
).fit_transform(paper_embeddings)
paper_embeddings_2d.shape

>>> (10046, 2)
```

2次元に変換できました。どんなマップになったか確認してみましょう。  
```python
plt.scatter(
    paper_embeddings_2d[:,0],
    paper_embeddings_2d[:,1],
    cmap="coolwarm",
    c=paper_df["year"],  # 出版年で着色
    s=3,
)
plt.colorbar(label="year")
plt.show()
```
![umap_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/ba0cfa33-a928-0d23-ac54-3e936c587a3c.png)


出版年で着色すると最近ホットなエリアは分かりますが、どんなトピックなのか把握できません。
マップをクラスタリングしてトピックを導出してみます。

# クラスタリング

HDBSCANにより分布の密度・距離ベースでクラスタリングします。
```python
hdbscan = fast_hdbscan.HDBSCAN(**HDBSCAN_PARAMS).fit(paper_embeddings_2d)
```

結果を確認してみましょう。
```python
plt.scatter(
    paper_embeddings_2d[:,0],
    paper_embeddings_2d[:,1],
    cmap="rainbow",
    c=hdbscan.labels_,
    s=3,
)
plt.colorbar(label="HDBSCAN Predict")
plt.show()
```
![hdbscan_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/d27d9ca6-5540-caac-f500-8b3f728b698d.png)


```python
Counter(hdbscan.labels_).most_common(10)

>>> [(np.int64(-1), 2932),
 (np.int64(91), 497),
 (np.int64(32), 284),
 (np.int64(63), 238),
 (np.int64(97), 238),
 (np.int64(58), 227),
 (np.int64(37), 222),
 (np.int64(16), 175),
 (np.int64(87), 174),
 (np.int64(103), 147)]
```

-1が最も多く、クラス数は100以上になりました。  
（fast_hdbscanは外れ値を-1でラベリングします）  

クラスのユニーク数が多すぎるので、クラスを統合しつつ -1を近傍クラスに振り分けます。  
クラス統合はKmeans、-1の振り分けはKNNで行います。
```python
# KNN class
class FaissKNeighbors:
    def __init__(self, k=5):
        self.index = None
        self.y = None
        self.d = None
        self.k = k

    def fit(self, X, y):
        self.d = X.shape[1]
        self.index = faiss.IndexFlatL2(self.d)
        self.index.add(X.astype(np.float32))
        self.y = y

    def predict(self, X):
        X = np.reshape(X, (-1, self.d))
        distances, indices = self.index.search(X.astype(np.float32), k=self.k)
        votes = self.y[indices]
        predictions = np.array([np.argmax(np.bincount(x)) for x in votes])
        return predictions


# クラスタリング結果の統合
clustering_df = pd.DataFrame(paper_embeddings_2d, columns=["x", "y"])
clustering_df["label"] = hdbscan.labels_

inlier_flag = clustering_df["label"] != -1
if clustering_df["label"][inlier_flag].nunique() > CLUSTERING_MAX_NUM:
    kmeans = KMeans(n_clusters=CLUSTERING_MAX_NUM, random_state=0)
    cluster_mean = np.zeros((clustering_df["label"][inlier_flag].nunique(), 2))
    for i, label in enumerate(np.sort(clustering_df["label"][inlier_flag].unique())):
        mean_x = np.mean(paper_embeddings_2d[clustering_df["label"] == label, 0])
        mean_y = np.mean(paper_embeddings_2d[clustering_df["label"] == label, 1])
        cluster_mean[i] = [mean_x, mean_y]

    mean_label = kmeans.fit_predict(cluster_mean)
    grouping_dict = {i: c for i, c in enumerate(mean_label)}
    clustering_df["label"] = clustering_df["label"].replace(grouping_dict)

knn = FaissKNeighbors(**KNN_PARAMS)
knn.fit(paper_embeddings_2d[inlier_flag], clustering_df["label"][inlier_flag].to_numpy())
clustering_df.loc[~inlier_flag, "label"] = knn.predict(paper_embeddings_2d[~inlier_flag])
```

```python
plt.scatter(
    paper_embeddings_2d[:,0],
    paper_embeddings_2d[:,1],
    cmap="rainbow",
    c=clustering_df["label"],
    s=3,
)
plt.colorbar(label="HDBSCAN Predict")
plt.show()
```
![clustering_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/9d1d11db-533d-6ba7-d96a-31f41778a588.png)



クラス数を`CLUSTERING_MAX_NUM`以下に統合し、外れ値を振り分けできました。


# トピック導出

論文タイトルからワード（単語・熟語）を抽出し、クラス特有のワード≒トピックを導出します。  
ワードの導出は、[BERTopic](https://maartengr.github.io/BERTopic/getting_started/ctfidf/ctfidf.html)を参考に c-TF-IDF(クラス単位のTF-IDF) で行います。  


まずはspaCyを使用し、名詞・固有名の単語・熟語を抽出します。
```python
# 形態素解析器の用意
nlp = spacy.load('en_core_web_sm')

def get_ngram(
    texts: list[str],
    gram_num: int = 2,
    stopwords: list[str] = [],
    target_pos: list[str] = []
) -> list[str]:
    """熟語(n-gram)を取得。
    gram_numで指定した数のトークンを連結し、熟語とする。

    Args:
        texts (list[str]):
            処理対象文章.
        gram_num (int, optional):
            連結するトークンの数。デフォルトは2。
        stopwords (list[str]], optional):
            ストップワードのリスト。デフォルトは [].
        target_pos (list[str]], optional):
            抽出したい品詞がある場合に使用。
            [] の場合はすべての品詞を取得します。デフォルトは [].

    Returns:
        List[str]:
            n-gramのリスト。
    """


    pattern = r"o+"
    join_str = '_'

    results = []
    for text in tqdm(texts):
        pos_str = ''
        tokens = []
        n_grams = []
        doc = nlp(text.lower())
        if target_pos:
            for token in doc:
                tokens.append(token.text)
                if (token.pos_ in target_pos) & (token.text not in stopwords):
                    pos_str += 'o'
                else:
                    pos_str += 'x'
        else:
            for token in doc:
                tokens.append(token.text)
                if token.text not in stopwords:
                    pos_str += 'o'
                else:
                    pos_str += 'x'

        for match in re.finditer(pattern, pos_str):
            start = match.start()
            end = match.end()
            length = end-start
            if length >= gram_num:
                for s in range(start, end-gram_num+1):
                    n_grams.append(
                        join_str.join(
                            [inflection.singularize(w) for w in tokens[s:s+gram_num]]
                        )
                    )
        results.append(n_grams)

    return results


stopwords = list(nlp.Defaults.stop_words)

# 熟語の抽出
bi_grams = get_ngram(
    paper_df["title"].to_numpy(),
    gram_num=2,
    stopwords=stopwords,
    target_pos=["NOUN", "PROPN"],
)
# 単語の抽出
mono_grams = get_ngram(
    paper_df["title"].to_numpy(),
    gram_num=1,
    stopwords=stopwords,
    target_pos=["NOUN", "PROPN"],
)

# 熟語に含まれなかった単語を抽出
bi_grams_uniq = list(set(np.concat(bi_grams)))
bi_grams_split_uniq = list(set("_".join(bi_grams_uniq).split("_")))
# 熟語リストと単語リストの結合
merge_grams = []
for bi_gram, mono_gram in zip(bi_grams, mono_grams):
    use_mono_gram = [m for m in mono_gram if m not in bi_grams_split_uniq]
    merge_grams.append(bi_gram + use_mono_gram)
```
<br>

抽出したワードに対してc-TF-IDFを適用します。
```python
# クラス単位でワードを結合
cluster_words_list = []
for label in sorted(clustering_df["label"].unique()):
    cluster_words_list.append(
        " ".join([" ".join(w) for w, b in zip(merge_grams, clustering_df["label"]==label) if b])
    )
# tf-idf
vectorizer = TfidfVectorizer()
vectorizer = vectorizer.fit(cluster_words_list)
_cluster_tfidf = vectorizer.transform(cluster_words_list)
cluster_tfidf = _cluster_tfidf.toarray()
# クラス単位でtf-idf上位3単語を抽出
class_keywords_dict = {}
words = vectorizer.get_feature_names_out()
for label in sorted(clustering_df["label"].unique()):
    tmp_topk = cluster_tfidf[label].argsort()[::-1]
    class_keywords_dict[label] = " ".join(words[tmp_topk[:3]])

cluster_topic_words = [class_keywords_dict[i] for i in clustering_df["label"].to_numpy()]

# トピック抽出結果
[(k, v) for k, v in list(class_keywords_dict.items())[:3]]
>>> [(np.int64(0), 'language_model language_understanding text_generation'),
 (np.int64(1), 'neural_network vector_quantization precision_quantization'),
 (np.int64(2), 'influence_maximization search_algorithm multiwinner_voting')]
```

# インタラクティブに可視化

着目すべきクラスを把握しやすいよう、クラスの成長度合いを導出し色分けに使用します。
```python
# クラス別に2022~2024年の論文割合を集計し、回帰係数から成長度を定義
## 論文割合集計
cluster_year_count_dict = defaultdict(list)
year_min, year_max = np.min(GROWTH_CALC_YEARS), np.max(GROWTH_CALC_YEARS)
year_list = np.arange(year_min - 1, year_max + 1)
for year in tqdm(year_list):
    year_flag = paper_df["year"] == year
    counter = Counter(clustering_df["label"][year_flag])
    for key in clustering_df["label"].unique():
        counter.setdefault(key, 0)
    for k, v in counter.items():
        cluster_year_count_dict[k].append(
            v / (np.sum(clustering_df["label"] == k) + 1e-6)
        )
## 回帰係数を集計
cluster_growth_rate_dict = {}
for label, count in cluster_year_count_dict.items():
    slope, _, _, _, _ = linregress(year_list, count)
    cluster_growth_rate_dict[label] = slope
```
<br>

成長度に応じてクラスを青～赤に振り分けます。
```python
cmap = plt.get_cmap("coolwarm")


def get_color(value, vmin, vmax):
    # 正規化して0から1の範囲にマッピング
    norm_value = (value - vmin) / (vmax - vmin)
    # カラーマップから色を取得
    return to_rgb(cmap(norm_value))


cluster_growth_rates = list(cluster_growth_rate_dict.values())
color_range = max(np.abs(np.min(cluster_growth_rates)), np.max(cluster_growth_rates))
marker_colors = [
    get_color(cluster_growth_rate_dict[i], -color_range, color_range)
    for i in clustering_df["label"].to_numpy()
]
```
<br>

可視化時に論文タイトルなどが分かるよう、ホバーテキストも準備します。
```python
hover_texts = [
    f"Growth rate: {np.round(slope, 3)}\n {row[0]}\n {row[1].upper()} - {row[2]}"
    for row, slope in zip(
        paper_df[["title", "key_conference", "year"]].astype(str).to_numpy(),
        [cluster_growth_rate_dict[i] for i in clustering_df["label"].to_numpy()]
    )
]
```
<br>

準備した情報を使って、DataMapPlotで可視化します。  
```python
plot = datamapplot.create_interactive_plot(
    paper_embeddings_2d,
    [w.replace(" ", ", ").replace("_", " ") for w in cluster_topic_words],
    hover_text=hover_texts,
    enable_search=True,
    marker_size_array=2,
    # height=1000,
    marker_color_array=marker_colors,
)
# HTML保存
plot.save("AAAI_Landscape.html")
```
<br>

これで本記事の冒頭に挙げたようなランドスケープマップが出来ました！  
DataMapPlotはWordCloudライクな可視化なども可能なので、興味ある方は[公式ドキュメント](https://datamapplot.readthedocs.io/en/latest/intro_splash.html)を確認してみてください。

![landscape.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/acc79e6d-45cd-4a72-9e2b-dfc4c4109eb4.gif)




ランドスケープマップをもとに、成長傾向(色が赤い)クラス・停滞傾向(色が青い)クラスを確認してみます。

- 成長傾向(色が赤い)クラスの例  
  `radiance fields`や`point cloud`, `super resolution`などの画像系クラスが成長傾向のようです。  
  画像系の中でも発展的なタスクが挙がっている印象です。

![pickup1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/9e7bc205-45cd-ddcf-e25b-e59174a00a46.png)




- 停滞傾向(色が青い)クラスの例  
  `acronym disambiguation`(頭字語の曖昧性解消)や`document summarization`, `word embedding`などの言語系クラスが停滞傾向のようです。  
  上記領域は現在ホットなLLMの研究に包含されると考えられるため、タイトルで明示する論文が減少しているのかもしれません。

![pickup2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3476911/b12f60e7-a35a-b786-71fa-ab8e51b95efa.png)


今回はAAAIだけでランドスケープマップを作りましたが、その他AI関連の学会データ(CVPR, EMNLPなど)を追加することで、AI分野全体の傾向・トピックが俯瞰できるリッチなマップが出来ると思います。  
各学会のデータもdblp APIで取得可能ですので、ぜひ確認してみてください！


---
最後まで読んでいただき、ありがとうございました！
本記事の内容に誤りなどあれば、コメントにてご教授お願いいたします。


# Reference
- [AAAI学会(2020~2024)の研究トレンドを分析してみる](https://qiita.com/anyai_corp/items/ec6ba9393faf90008f68#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E7%94%A8%E6%84%8F
)
- [SPECTER2: Adapting scientific document embeddings to multiple fields and task formats](https://allenai.org/blog/specter2-adapting-scientific-document-embeddings-to-multiple-fields-and-task-formats-c95686c06567)
- [BERTopic](https://maartengr.github.io/BERTopic/index.html)
- [DataMapPlot](https://datamapplot.readthedocs.io/en/latest/)