見出し画像

[趣味研究][R18]番外編:アダルトサイトとネットワーククラスタリング


注意

スクレイピングの対象としてアダルトサイトを選んでいることから、本記事には過激な性的表現が含まれているので、未成年の方や、そうした表現が苦手な方は閲覧を控えてください。ご了承いただける方のみスクロールして本記事をお読みください。

*なお本来であれば自主的にR18指定をかけたいのですが、noteにはその機能がなく、公式からR18と指定されるのを待つしかないようです。

*手法の章にはアダルトコンテンツはないのでそこだけ見ることもできます




















この記事で分かること

  • ウェブスクレイピング

  • ネットワーククラスタリング

概要

DLsiteで販売されている成人向け漫画・CG集を対象に、ジャンルタグ同士の関係をネットワークを用いて可視化しました。具体的には、作品つくタグの共起頻度を調べて、共起ネットワークを作成し、そのネットワークをLouvainアルゴリズムでクラスタリングしました。

目的


過去の記事ではショタ・男の娘にのみ焦点を当ててきましたが、よく考えたら成人向け漫画・CG集全体についての考察は何もしていませんでした。ショタ・男の娘の全体における位置づけを知るとともに、最近、少し勉強しているネットワーク科学の復習もかねて今回の記事を書くことにしました。
あと、なぜ自分の性癖が今のように変化(深化)していったのかをネットワークで可視化することで、スタートポイントからたどったりして確認できたら面白いかなと思いました。

手法

今回使う関数(google colabでの使用を想定しています)

使用するパッケージなど

#!pip install japanize_matplotlib
#!apt-get -y install fonts-ipafont-gothic
import requests
from bs4 import BeautifulSoup
#上述二つがscpapingの基本的なパッケージ
import pandas as pd
import re
import time
import json
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
import pandas as pd
import tqdm
import networkx as nx
import numpy as np
import random
import community.community_louvain as community_louvain # Louvainアルゴリズムを利用するためのライブラリ
#NetworkXを日本語で使えるようにする
from matplotlib import font_manager

モジュラリティ

モジュラリティとはモジュラリティは、ネットワーク分割の「良さ」を測る手法です。
良い分割とは、直観的には、「同じコミュニティ内のエッジ数が多く、異なるコミュニティ間のエッジ数が少ない状態」だろうなとここでは考えます。
これを表現する指標として、次のようなものが考えられます。

$$
Q' = \delta(c_i, c_j)\frac{1}{2m} \sum_{ij} A_{ij}
$$

-$${A_{ij}}$$ はノード $${i}$$ とノード $${j}$$ 間のエッジの存在を示す隣接行列で、$${\sum_{ij} A_{ij} = 2m}$$ です。
- $${m}$$ はネットワーク内の全エッジ数です。
- $${c_i}$$ はノード $${i}$$ の所属するコミュニティです。
- $${\delta(c_i, c_j)}$$ はノード i とノード iが同じコミュニティに属する場合に1、それ以外の場合に0をとります。


式の気持ち
この式では、ノード i とノード j が同じコミュニティに属しているとき($${\delta(c_i, c_j) = 1}$$)、かつノード間にエッジが存在すれば($${A_{ij} = 1}$$)、$${Q}$$ は大きくなります。逆に、ノード i とノード jが同じコミュニティに属していないとき($${\delta(c_i, c_j) = 0}$$)、ノード間にエッジがなければ($${A_{ij} = 0}$$)、$${Q'}$$ は小さくなります。

問題点

Q' は [0, 1] の範囲の値をとりますが、全てのノードが1つのコミュニティに属している場合(すなわち、常に $${\delta(c_i, c_j) = 1}$$ である場合)、Q' は最大値1をとってしまいます。良い分割を示す指標であるはずが、分割を行わない場合に最大化されるのは望ましくありません。例えば、コミュニティ分割の最適化を行った結果、「全てのノードが1つのコミュニティに属します」と返されたら、誰もが不満を感じるでしょう。
そこで、次のような Qを指標とします。

モジュラリティの正式な定義

$$
Q = \delta(c_i, c_j)\frac{1}{2m} \sum_{ij}\left[A_{ij} - \frac{k_i k_j}{2m} \right]
$$

ここで、
-$${k_i}$$ はノード iの次数(つながっているエッジの数)です。
-$${\frac{k_i k_j}{2m}}$$ はランダムネットワークにおけるノード i とノード j間のエッジの期待値です。
式の気持ち
この式では、$${\frac{k_i k_j}{2m}}$$ というランダムな状態よりも高い確率でノード iとノード j が隣接している場合、つまり同じコミュニティに属していると認められる場合に Qは大きくなります。このように、Q は 先ほどのQ' をランダムネットワークと比較し、相対化したものと言えます。

実装

def modularity(G, community):
    Q = 0
    nodes = list(G.nodes())
    m = len(G.edges())  # 全エッジ数
    A = nx.to_numpy_array(G)  # 隣接行列をNumPy配列として取得

    for i, node_i in enumerate(nodes):
        for j, node_j in enumerate(nodes):
            if community[node_i] == community[node_j]:
                k_i = G.degree(node_i)  # ノードiの次数
                k_j = G.degree(node_j)  # ノードjの次数
                Q += A[i, j] - (k_i * k_j) / (2 * m)

    Q = (1 / (2 * m)) * Q
    return Q


Louvainアルゴリズム

次のようなアルゴリズムに則る

  1. まずネットワークの全てのノードを独立したコミュニティとする。

  2. ノードiの隣接ノードをN=[N_j1, N_j2, ….N_jn]とする。

  3. iのコミュニティを[N_j1, N_j2, ….N_jn]のコミュニティと同じにしたとき、どれだけモジュラリティが増加するか調べる

  4. モジュラリティに最大の増加をもたらしたコミュニティを新たにiのコミュニティとする。

  5. 全てのノードについて2-4を行う。

  6. 1と5のモジュラリティを比較し、十分大きな増加がみられた場合は7へ、そうでない場合は終了

  7. コミュニティC=[C1,C2…Cn]が得られたとする。今度は、これらCkを一つのノードとして新たなネットワークを構築する(ネットワークのリンクやその重みは元のネットワークの関係を反映する形にする)

  8. 7に対して1-6を繰り返し

class Louvain:
    def __init__(self, graph):
        self.graph = graph
        self.partition = {node: node for node in graph.nodes()}  # 各ノードを自分自身のコミュニティに初期化
        self.modularity = self.calculate_modularity()

    def calculate_modularity(self):
        """ 自作のモジュラリティ関数を使用して現在のモジュラリティを計算 """
        return modularity(self.graph, self.partition)

    def run(self):
        """ Louvainアルゴリズムを実行 """
        while True:
            initial_modularity = self.modularity
            self.one_level()
            new_modularity = self.calculate_modularity()

            if new_modularity - initial_modularity < 1e-6:
                break

            self.reconstruct_graph()

        return self.partition

    def one_level(self):
        """ 一段階の最適化を実行 """
        nodes = list(self.graph.nodes())
        np.random.shuffle(nodes)
        improved = True

        while improved:
            improved = False
            for node in nodes:
                best_community = self.partition[node]
                best_increase = 0

                for neighbor in self.graph.neighbors(node):
                    current_community = self.partition[neighbor]
                    if current_community == best_community:
                        continue

                    self.partition[node] = current_community
                    increase = self.calculate_modularity() - self.modularity

                    if increase > best_increase:
                        best_increase = increase
                        best_community = current_community

                self.partition[node] = best_community

                if best_increase > 0:
                    improved = True
                    self.modularity += best_increase

    def reconstruct_graph(self):
        """ コミュニティをスーパーノードとして再構築する """
        communities = {}
        new_graph = nx.Graph()

        for node, community in self.partition.items():
            if community not in communities:
                communities[community] = len(communities)
            new_node = communities[community]
            new_graph.add_node(new_node)

        for node1, node2, data in self.graph.edges(data=True):
            community1 = communities[self.partition[node1]]
            community2 = communities[self.partition[node2]]
            weight = data.get('weight', 1.0)

            if new_graph.has_edge(community1, community2):
                new_graph[community1][community2]['weight'] += weight
            else:
                new_graph.add_edge(community1, community2, weight=weight)

        self.graph = new_graph
        self.partition = {node: node for node in new_graph.nodes()}


データセット

今回はDLsiteさんのコミック・CG集のカテゴリーに属する全ての作品を収集しました。

*スクレイピングはサイトの最新の規約を確認し、自己責任で実行してください。私は一切の責任を負いません。

データスクレイピング

コード内のurlの部分および、inf_scraper関数は以下の過去記事を参照してください。


 #漫画 
rank_urls = []
for page in range(1,1314):
  url = ""
  rank_urls.append(url)

count = 100
for rankurl in tqdm.tqdm(rank_urls):
  df = inf_scraper(rankurl)
  df.to_json(f"comic_{count}.json", orient='records', lines=True)
  count+=100

page = 1
file_name = "comic_"+f"{page}"+"00.json"
comic_df = pd.read_json(file_name, orient='records', lines=True)
for page in tqdm.tqdm(range(2,1314)):
  file_name = "comic_"+f"{page}"+"00.json"
  df = pd.read_json(file_name, orient='records', lines=True)
  comic_df = pd.concat([base_df, df], ignore_index=True)
 #CG集 
rank_urls = []
for page in range(1,916):
  url = ""
  rank_urls.append(url)

count = 100
for rankurl in tqdm.tqdm(rank_urls):
  df = inf_scraper(rankurl)
  df.to_json(f"comic_{count}.json", orient='records', lines=True)
  count+=100

page = 1
file_name = "comic_"+f"{page}"+"00.json"
cg_df = pd.read_json(file_name, orient='records', lines=True)
for page in tqdm.tqdm(range(2,916)):
  file_name = "comic_"+f"{page}"+"00.json"
  df = pd.read_json(file_name, orient='records', lines=True)
  cg_df = pd.concat([base_df, df], ignore_index=True)

データの概観

データ上では1998年12月06日の作品が最古の作品でした。
1990sはタグ付けされていない作品が多かったので今回は2000年以降のモノのみを扱っていこうと思います。

以下の図は「全年度の中で登場頻度TOP20に入ったタグ」の経年変化を示しています。上位4つ(中出し・巨乳・おっぱい・フェラチオ)がとびぬけていることがわかります。TOP50まで見てみても、上位4つとそれ以外という構図は変わらないみたいです。それ以外のジャンルが等しく拮抗しているのは面白いですね。

TOP20のタグの変遷
TOP50のタグの変遷


結果

ネットワークを作るために、まず共起行列というものを準備する。
「共起」は読んで字のごとく、共に現れることを指し、ここでは、例えば作品AにタグXとタグYが同時にタグ付けされていたらそれらのXとYは「共起」したと考える。

共起した者同士を結ぶネットワークを共起ネットワークと呼び、今回作るネットワークはその共起ネットワークである。

そして、その共起ネットワークを作るために必要なのが共起関係をまとめた共起行列である。

まあ見た方がはやい

共起行列

これが共起行列です。この表から例えば[魔法少女・SM]のタグは13回同時にタグ付けされていることが分かる。
今回は100回以上共起しているタグのペアを対象にネットワークを構築した。

#漫画とCG集のデータフレームを結合して、クリーニング
comic_df = pd.read_json("comic_all.json", orient='records', lines=True)
cg_df = pd.read_json("cg_all.json", orient='records', lines=True)
merged_df = pd.concat([comic_df, cg_df], ignore_index=True).sort_values('date', ascending=True)
merged_df = merged_df[merged_df['tag'].apply(lambda x: x != [None])]

#年代別に分ける。
til2005 = merged_df[(20000000<merged_df["date"])&(20060000>merged_df["date"])]
til2010 = merged_df[(20060000<merged_df["date"])&(20110000>merged_df["date"])]
til2015 = merged_df[(20110000<merged_df["date"])&(20160000>merged_df["date"])]
til2020 = merged_df[(20160000<merged_df["date"])&(20210000>merged_df["date"])]
tilnow = merged_df[(20210000<merged_df["date"])]

#気にしなくていい
df_list = [til2005, til2010, til2015, til2020, tilnow]
df_list = [til2005, til2010, til2015, til2020, tilnow]
tagcol_list = [til2005["tag"].to_list(), til2010["tag"].to_list(), til2015["tag"].to_list(), til2020["tag"].to_list(), tilnow["tag"].to_list()]

#共起行列の作成
def create_cooccurrence_matrix(data):
    # ユニークな要素を取得し、索引として使用
    unique_items = set(item for sublist in data for item in sublist)
    unique_items = sorted(list(unique_items))

    # 要素のインデックス辞書を作成
    index_dict = {item: idx for idx, item in enumerate(unique_items)}

    # 共起行列をゼロで初期化
    co_matrix = np.zeros((len(unique_items), len(unique_items)), dtype=int)

    # 共起行列を更新
    for sublist in data:
        if len(sublist) > 1:
            for i in range(len(sublist)):
                for j in range(i + 1, len(sublist)):
                    idx1 = index_dict[sublist[i]]
                    idx2 = index_dict[sublist[j]]
                    co_matrix[idx1][idx2] += 1
                    co_matrix[idx2][idx1] += 1

    # Pandas DataFrameに変換
    co_matrix_df = pd.DataFrame(co_matrix, index=unique_items, columns=unique_items)
    return co_matrix_df

G_list=[]
for lst in tagcol_list:
    cooccurrence_matrix = create_cooccurrence_matrix(lst)
    G = nx.Graph()

    # 共起行列からノードとエッジを追加
    for i in cooccurrence_matrix.columns:#列名(not index)
        for j in cooccurrence_matrix.index:#行名(not index)
            weight = cooccurrence_matrix.loc[j, i]
            if weight > 100 and i != j:
              # タグ名-タグ名のエッジを追加して、共起頻度を対数で重みづけ
                G.add_edge(i, j, weight=np.log(weight))  # 重みを対数で設定

    # コンポーネントごとに直径をチェックし、条件に合うものだけを残す
    for component in list(nx.connected_components(G)):
        subgraph = G.subgraph(component)
        if nx.diameter(subgraph) < 3:
            G.remove_nodes_from(component)
    
    G_list.append(G)

#ネットワーククラスタリング+可視化

#ランダムに色を選択する関数
def generate_random_color():
    return "#"+''.join([random.choice('0123456789ABCDEF') for j in range(6)])

# 定義済みの色
defined_colors = ['skyblue', 'orange', 'lightgreen', 'pink', 'gray']

# グラフが空でなければ、クラスタリングと可視化を実行
for G in G_list:
  if not nx.is_empty(G):
      # Louvainアルゴリズムによるコミュニティ検出
      partition = community_louvain.best_partition(G)

      # コミュニティ数に応じて色を割り当て
      communities = set(partition.values())
      if len(communities) > len(defined_colors):
          extra_colors_needed = len(communities) - len(defined_colors)
          random_colors = [generate_random_color() for _ in range(extra_colors_needed)]
          color_map = defined_colors + random_colors
      else:
          color_map = defined_colors

      # コミュニティごとに色分け
      node_colors = [color_map[list(communities).index(partition[node])] for node in G.nodes()]

      # ネットワークの描画
      plt.figure(figsize=(20, 15))
      pos = nx.spring_layout(G)  # レイアウトの設定
      edges = G.edges(data=True)

      # エッジの太さを重みに基づいて調整
      edge_widths = [d['weight'] * 0.1 for (u, v, d) in edges]

      nx.draw(G, pos, node_color=node_colors, with_labels=True, node_size=1000, edge_color='gray', width=edge_widths, font_family="IPAexGothic", font_size=8)
      plt.title('共起ネットワーク (Louvainクラスタリング)')
      plt.show()
  else:
      print("経路長が3未満のため、グラフは表示されません。")



このコードを実行すると200-2005年は共起頻度100以上のペアが少なかったためネットワークが形成されませんでした。


2006-2010
2011-2015
2016-2020
2020-現在

本当はさらにGephiなどの専用ソフトできれいにした方がいいのですが、面倒くさいのでこのくらいにしておきましょう。

ディスカッション

ちょっと最近忙しいので手短に男の娘・ショタに限定して語ります(また余裕ができたら追記します)

  1. 一貫してショタと男の娘は同じクラスタに属している

  2. 最初はレズとショタ・男の娘が同じクラスタにあったのは面白い

  3. おねショタ→ショタ→男の娘→ゲイ/男性同士がどうやらルートとして存在する。

今後

もう少しネットワークの特徴量(中心性など)を見ていこうと思います

過去記事もよろしくお願いします

この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?