週刊少年ジャンプの短命作品を,機械学習で予測する (前編:データ分析)

  • 22
    いいね
  • 0
    コメント

1. はじめに

 週刊少年ジャンプ(以下,ジャンプ)は,日本で最も売れている漫画雑誌1です.言うまでもなく,私は大ファンです.

 ジャンプ編集部の連載会議は非常にシビアです.ジャンプ作家の奮闘を描いたフィクション漫画「バクマン。」では,編集部が毎号の読者アンケートをもとに各漫画の人気を評価し,掲載順や打ち切り作品を決定する様子が描かれています2.連載開始から10週以内(単行本約1冊分)で連載が打ち切られてしまうことも珍しくありません.とても厳しい世界です.

 本記事では,機械学習を使って,短命作品(10週以内に終了する作品)の予測を行います.究極の目標は,ジャンプ編集部より先に打ち切り作品を予測し,好みの作品が危ない場合はアンケートを出して打ち切りを回避することです3.我々は読者アンケートの結果を知ることができないので,掲載順の履歴を入力とし,短命作品か否かを出力する多層パーセプトロン4TensorFlowで実装します.訓練には,Scrapy文化庁メディア芸術データベースから取得した,約46年分の目次情報を利用します.

 なお,本記事は2部構成の予定です.前編(今回)ではデータの取得と分析,後編(次回)ではデータの学習とテストについて書きます.

analysis.png

2. 環境

 anacondaで,以下のような仮想環境comicを作成しました.

conda create -n comic python=3.5
source activate comic
conda install pandas matplotlib jupyter notebook scipy scikit-learn seaborn
pip install tensorflow

 ymlファイルはこちらです.後編で必要になるので,tensorflowscikit-learnを入れてあります.また,可視化にpairplot()を使うので,seabornを入れます.

3. データの取得

3-1. ソース

 文化庁メディア芸術データベースに,約46年間分(1969年11月3日号-2016年7月25日号)のジャンプの目次が公開されています5

media_art.PNG

wj.PNG

index.PNG

上図は,ジャンプ1969年11月3日号の目次を調査した例です.残念ながらAPIはないので,Scrapyを使ってスクレイピングします.

3-2. スクレイピング

 文化庁のサーバへの負担を極力抑えるため,可能な限り私のgithubから加工済みデータを取得頂ますようお願い致します.以下,ご参考までにscrapyの設定等を記載します.

 スクレイピングには様々な方法がありますが,今回は代表的なフレームワークであるScrapyを利用します.Scrapyの詳細については,本記事末尾の参考欄をご参照ください.まずは,以下のコマンドで,本記事用のプロジェクトcomicを作成します.

scrapy startproject comic

 すると,以下のようなディレクトリが作成されるはずです(公式チュートリアル).

comic/
    scrapy.cfg            # deploy configuration file

    comic/             # project's Python module, you'll import your code from here
        __init__.py

        items.py          # project items definition file

        pipelines.py      # project pipelines file

        settings.py       # project settings file

        spiders/          # a directory where you'll later put your spiders
            __init__.py

 以下のcomic_spider.pycomic/spidersに置きます.

comic_spider.py
# -*- coding: utf-8 -*-

import scrapy


class WjSpider(scrapy.Spider):
    """
    start_urlsを起点に再帰的に以下の目次情報を抽出するspiderです.
    ここでstart_urlsは,文化庁メディア芸術データベースに登録されている
    週刊少年ジャンプの目次情報のうち,最も古いもの(1969年11月3日号)です.
    - year: 発行年
    - no: 号数
    - title: 作品名
    - author: 著者
    - color: カラーか否か
    - pages: 掲載ページ数
    - start_page: 作品のスタートページ
    - best: 巻頭から数えた掲載順
    - worst: 巻末から数えた掲載順
    """

    name = 'wj'

    start_urls = [
        'http://mediaarts-db.bunka.go.jp/mg/magazines/323270'
    ]

    n_page = 0

    def parse(self, response):
        """ spider本体です. """

        year = int(response.css('section.block tr td::text').extract()[3][:4])
        try:
            no = int(response.css('section.block tr td::text').extract()[8])
        except ValueError:
            no = response.css('section.block tr td::text').extract()[8]

     # マンガ作品のみ抽出します.
        comics = [comic for comic in response.css('table.infoTbl2 tr')
                  if len(comic.css('td::text')) > 0
                  and comic.css('td::text')[0].extract() == 'マンガ作品']
        data = []

        for comic in comics:
            title = comic.css('a::text').extract_first()

            if not title:
                continue

            # ページ数が記載されていない作品があるので,例外処理が必要です.
            # 特に理由はないですが,無記載の作品は10ページとして処理を進めます.
            try: 
                pages = float(comic.css('td::text')[6].extract())
            except ValueError:
                pages = 10

            # 「いぬまるだしっ」等,1週に複数話掲載されている作品に対応するため
            # data中にすでにtitleが含まれる場合は,新規datumとして登録せずに,
            # 既存のdatumのページ数のみ加算します.
            if len(data) > 0 and title in [datum['title'] for datum in data]:
                data[[datum['title'] for datum in
                      data].index(title)]['pages'] += pages

            else:
                data.append({
                    'year': year,
                    'no': no,
                    'title': comic.css('a::text').extract_first(),
                    'author': comic.css('td::text')[3].extract(),
                    'subtitle': comic.css('td::text')[4].extract(),
                    'color': comic.css('td::text')[7].extract().count('カラー'),
                    'pages': pages,
                    'start_page': float(comic.css('td::text')[5].extract())})

        # 企画物のミニマンガを除外するため,合計5ページ以下のdatumはリストから除外します.
        filtered_data = [datum for datum in data if datum['pages'] > 5]
        for n, datum in enumerate(filtered_data):
            datum['best'] = n + 1
            datum['worst'] = len(filtered_data) - n
            yield datum

        # 次号の情報を再帰的に取得します.
        next_page = response.css('li.nxt a::attr(href)').extract_first()
        if next_page is not None:
            next_page = response.urljoin(next_page)
            yield scrapy.Request(next_page, callback=self.parse)

 
 泥臭い話ですが,一部のギャグ漫画の扱いに苦労しました.例えば,「いぬまるだしっ」は,基本的に一週間に2話ずつ掲載していましたが,データベースでは各話が別々の行に記載されています.これらを1つの作品として見なす必要がるので,当該comictitledata中にある場合は,別datumとしてdataに追加せず,既存のdatumpagesを加算する処理を行っています.また,例えば「ピューと吹く!ジャガー」は,その人気に関係なく(実際めちゃくちゃ面白かったです),連載中は常に雑誌の最後に掲載されていました.これを外れ値として除外するかどうかで悩みましたが,結局残すことにしました.

 サーバに負荷をかけないよう,必ずsettings.pyDOWNLOAD_DELAYを設定する必要があります(デフォルトでコメントアウトされていました).また,日本語でデータを吐き出したいので,FEED_EXPORT_ENCODINGutf-8に設定します.

settings.py

### ----- 略 -----

DOWNLOAD_DELAY = 3
FEED_EXPORT_ENCODING = 'utf-8'

### ----- 略 -----

 以下を実行して,データを取得します.

scrapy crawl wj -o wj.json

3. データの分析

 実は,wj.jsonだけででかなり遊べます6.notebookはこちらですので,使っていただけると幸いです.なお,以下では,dataディレクトリ配下にwj.jsonがあることを前提に話を進めます.

3.1 準備

日本語で各作品のタイトルを表示したいので,matplotlibで日本語を描画 on Ubuntuを参考に設定します.Ubuntu以外をお使いの方は,適宜ご対応ください.

import json
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import pandas as pd

sns.set(style='ticks')

import matplotlib
from matplotlib.font_manager import FontProperties
font_path = '/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf'
font_prop = FontProperties(fname=font_path)
matplotlib.rcParams['font.family'] = font_prop.get_name()

3.2 ComicAnalyzer

 wj.json分析用に,次のようなクラスComicAnalyzerを定義します.

ComicAnalyzer
class ComicAnalyzer():
    """漫画雑誌の目次情報を読みだして,管理するクラスです."""

    def __init__(self, data_path='data/wj.json', min_week=7, short_week=10):
        """
        初期化時に,data_pathにある.jsonファイルから目次情報を抽出します.
        - self.data: 全目次情報を保持するリスト型
        - self.all_titles: 全作品名情報を保持するリスト型
        - self.long_titles: min_week以上連載した全作品名を保持するリスト型
        - self.last_year: 最新の目次情報の年を保持する数値型
        - self.last_no: 最新の目次情報の号数を保持する数値型
        - self.end_titles: self.long_titlesのうち,self.last_yearおよび
                           self.last_noまでに終了した全作品名を保持するリスト型
        - self.short_end_titles: self.end_titlesのうち,short_week週以内に
                                 連載が終了した作品名を保持するリスト型
        - self.long_end_titles: self.end_titlesのうち,short_week+1週以上に
                                連載が継続した作品名を保持するリスト型
        """

        self.data = self.read_data(data_path)
        self.all_titles = self.collect_all_titles()
        self.long_titles = self.drop_short_titles(self.all_titles, min_week)
        self.last_year = self.find_last_year(self.long_titles[-100:])
        self.last_no = self.find_last_no(self.long_titles[-100:], self.last_year)
        self.end_titles = self.drop_continued_titles(
            self.long_titles, self.last_year, self.last_no)
        self.short_end_titles = self.drop_long_titles(
            self.end_titles, short_week)
        self.long_end_titles = self.drop_short_titles(
            self.end_titles, short_week + 1)

    def read_data(self, data_path):
        """ data_pathにあるjsonファイルを読み出して,全ての目次情報をまとめたリストを返します. """
        with open(data_path, 'r', encoding='utf-8') as f:
            data = json.load(f)
        return data

    def collect_all_titles(self):
        """ self.dataから全ての作品名を抽出したリストを返します. """
        titles = []
        for comic in self.data:
            if comic['title'] not in titles:
                titles.append(comic['title'])
        return titles

    def extract_item(self, title='ONE PIECE', item='worst'):
        """ self.dataからtitleのitemをすべて抽出したリストを返します. """
        return [comic[item] for comic in self.data if comic['title'] == title]

    def drop_short_titles(self, titles, min_week):
        """ titlesのうち,min_week週以上連載した作品名のリストを返します. """
        return [title for title in titles
                if len(self.extract_item(title)) >= min_week]

    def drop_long_titles(self, titles, max_week):
        """ titlesのうち,max_week週以内で終了した作品名のリストを返します. """
        return [title for title in titles
                if len(self.extract_item(title)) <= max_week]

    def find_last_year(self, titles):
        """ titlesが掲載された雑誌のうち,最新の年を返します. """
        return max([self.extract_item(title, 'year')[-1]
                   for title in titles])

    def find_last_no(self, titles, year):
        """ titlesが掲載されたyear年の雑誌のうち,最新の号数を返します. """
        return max([self.extract_item(title, 'no')[-1]
                   for title in titles
                   if self.extract_item(title, 'year')[-1] == year])

    def drop_continued_titles(self, titles, year, no):
        """ titlesのうち,year年のno号までに連載が終了した作品名のリストを返します. """
        end_titles = []
        for title in titles:
            last_year = self.extract_item(title, 'year')[-1]
            if last_year < year:
                end_titles.append(title)
            elif last_year == year:
                if self.extract_item(title, 'no')[-1] < no:
                    end_titles.append(title)
        return end_titles

    def search_title(self, key, titles):
        """ titlesのうち,keyを含む作品名のリストを返します. """
        return [title for title in titles if key in title]

3.3 分析

 それでは,ComicAnalyzerを使って遊んでみます.

wj = ComicAnalyzer()

 まずは,短命作品最新10タイトルの,最初の10週目までの掲載順(worst)をプロットしてみます.値が大きいほど,巻頭付近に掲載されていたことになります.

for title in wj.short_end_titles[-10:]:
    plt.plot(wj.extract_item(title)[:50], label=title[:6])
plt.ylabel('Worst')
plt.ylim(0,22)
plt.legend()

short.png

 ギャグマンガ日和などの企画物(出張作品)が入っているのが不満ですが,目次情報だけから除外する手立てはないです.あれ?「斉木楠雄」って10週以上連載していたんじゃ…?こういうときは,search_title()を使います.

wj.search_title('斉木', wj.all_titles)
# ['超能力者 斉木楠雄のΨ難', '斉木楠雄のΨ難']

len(wj.extract_item('超能力者 斉木楠雄のΨ難'))
# 7

wj.extract_item('超能力者 斉木楠雄のΨ難', 'year'), \
wj.extract_item('超能力者 斉木楠雄のΨ難', 'no')
# ([2011, 2011, 2011, 2011, 2011, 2011, 2011], [22, 27, 29, 33, 42, 43, 50])

len(wj.extract_item('斉木楠雄のΨ難'))
# 201

どうやら,「超能力者 斉木楠雄のΨ難」で試験的に7回読み切り掲載したあと,「斉木楠雄のΨ難」の連載を開始したみたいですね(wikipedia). 次は,近年のヒット作(独断)の最初の10話分の掲載順を表示します.

target_titles = ['ONE PIECE', 'NARUTO-ナルト-', 'BLEACH', 'HUNTER×HUNTER']
for title in target_titles:
    plt.plot(wj.extract_item(title)[:10], label=title[:6])
plt.ylim(0,22)
plt.ylabel('Worst')
plt.legend()

hit.png

 本記事とは直接関係ありませんが,個人的に気になったので,50話まで掲載順を見てみます.

target_titles = ['ONE PIECE', 'NARUTO-ナルト-', 'BLEACH', 'HUNTER×HUNTER']
for title in target_titles:
    plt.plot(wj.extract_item(title)[:100], label=title[:6])
plt.ylim(0,22)
plt.ylabel('Worst')
plt.legend()

hit50.png

 ある程度予想はしてましたが,やっぱりすごいですね.ちなみにですが,extract_item()を使って各話タイトルを取得しながら掲載順を見ると,マンガ好きの方はニヤニヤできます.

wj.extract_item('ONE PIECE', 'subtitle')[:10]

#['1.ROMANCE DAWN―冒険の夜明け―',
# '第2話!! その男"麦わらのルフィ"',
# '第3話 "海賊狩りのゾロ"登場',
# '第4話 海軍大佐"斧手のモーガン"',
# '第5話 "海賊王と大剣豪"',
# '第6話 "1人目"',
# '第7話 "友達"',
# '第8話 "ナミ登場"',
# '第9話 "魔性の女"',
# '第10話 "酒場の一件"']

 寄り道しすぎました.seabornpairplot()で相関分析をやってみます.同じ座標に複数の点が重なって非常に見づらいので,便宜上,各点を少しだけずらして見栄えを整えます.

end_data = pd.DataFrame(
    [[wj.extract_item(title)[1] + np.random.randn() * .3,
      wj.extract_item(title)[2] + np.random.randn() * .3,
      wj.extract_item(title)[3] + np.random.randn() * .3,
      wj.extract_item(title)[4] + np.random.randn() * .3,
      wj.extract_item(title)[5] + np.random.randn() * .3,
      '短命作品' if title in wj.short_end_titles else '継続作品']
     for title in wj.end_titles])
end_data.columns = ["Worst (week2)", "Worst (week3)", "Worst (week4)", 
                    "Worst (week5)", "Worst (week6)", "Type"]
sns.pairplot(end_data, hue="Type")

analysis.png

 青色は10週以上継続した作品,緑色は10週以内で終了した短命作品です.もっとガッツリ分かれると思っていたのですが,意外と分離が難しそうですね.おそらくですが,「超能力者 斉木楠雄のΨ難」のような試験的な読み切り作品がノイズになっているような気がします.掲載号の連続性で判別しても良いんですが,それだと休載作品と区別できないんですよね….悩ましいです.
 とりあえずこのデータのまま,機械学習してみようと思います.

4. おわりに

 現実逃避で,気がついたらこんなものを作ってしまいました.次回が本番ですので,お楽しみ頂ければ幸いです.最後までお読み頂き,ありがとうございました!

参考文献

 本記事の作成にあたっては,以下を参考にさせて頂きました.ありがとうございました!:bow:

  1. バクマン。:週刊少年ジャンプで連載されていた,漫画家漫画です.原稿料の話など,結構生々しい話が出てきて,面白かったです.
  2. Scrapy入門(1):すっきりまとまっており,大変参考に成りました.
  3. Scrapy Tutorial:ひと通り手を動かしたら,なんとなく使い方がわかりました.
  4. matplotlibで日本語を描画 on Ubuntu:作品名を日本語で表示する際,参考にさせて頂きました.

  1. 日本雑誌協会調べ.2015年10月1日~2016年9月30日における少年向けコミック誌発行部数男性向けコミック誌発行部数少女向けコミック誌発行部数女性向けコミック誌発行部数参照. 

  2. 作中では,主に読者アンケートの結果をもとに,掲載順や打ち切り作品を決定していました.以下の記事によると,ジャンプ編集部は「必ずしも読者アンケートの結果だけを考慮しているわけではない」と,これを否定したようです.「ジャンプ」編集部がアンケート至上主義の噂を否定も…読者は複雑 

  3. 前述したように,実際には,ジャンプ編集部は様々な要素を考慮して打ち切り作品を決定されています.本記事は,あくまでも一ジャンプファンの妄想として,ご理解頂ければと思います. 

  4. 予定です.性能によっては別の手法も検討します. 

  5. 2017年4月4日時点. 

  6. 当初の予想以上に楽しく遊べたので,二部構成にしました.