astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

技術ブログで長くPVを稼ぐ 4つの記事パターン

f:id:astamuse:20210106211214j:plain

あけましておめでとうございます。アスタミューゼでデザイナーをしている@YojiShirakiです。
年末年始は皆さんいかがでしたでしょうか?

さて、今回は年始という区切りということで、当ブログのアクセス情報を紐解いて技術ブログで PV を稼ぐ記事のパターンを4つほどご紹介したいと思います。

目次

当ブログの現況

このブログは2016年6月にスタートして、次の6月で丸5年になります。投稿記事数は189本。あと3ヶ月もすれば200本を超える規模になります。毎週1本の投稿を目安にして、大きく停滞することもなく継続できています。

ブログの成果としては

  • 採用に来る方が事前にブログを読んでいて良い印象を持っている
  • 当社の技術についての事前知識を持っている

というのが大きいです。もちろん採用応募にも貢献してますが 数×効能=成果 と考えると、皆さんに「雰囲気が良さそうと感じる」とか「技術に積極的そう」という良い心象形成ができていることのほうが価値があるように感じます。

ブログのアクセス数ってどうなの?

さて、そんな当ブログ。「それなりにアクセスあるのでは?」と思われるかも知れませんが、多いかどうかはともかく、記事数に比例した積もり方をしているわけではありません。

ブログのPV推移
ブログのPV推移

よく「コンテンツは資産だ」と言いますが記事には特質があり、それによって資産化する・しないが決まるため、その辺りの配合をマネージせねば全体としての資産性を向上させることができません。 今回はそういった課題意識も併せながら記事の特質を踏まえて PV を長く稼ぐ4つのパターンを紹介していきます。

技術ブログでPVを稼ぐ 4つの記事パターン

PVを稼ぐ記事 その1 「ハマれば強いニッチトップ記事」

PVを稼ぐ記事その1は「ニッチトップ」です。

まずは下記のグラフをご覧ください。これは2020年11月のアクセスについて、いつの投稿記事がPVを稼いでいるかを示したものです。

2020年11月にアクセスのあった記事の投稿月別PV数
2020年11月にアクセスのあった記事の投稿月別PV数

見ると、未だに2年前の2018年10月の記事がPVを稼いでいることがわかります。実はこれニッチトップ記事なんです。当該記事はこちら。

初めてのPXEブート - astamuse Lab

PXEブートについて非常に丁寧にかかれた記事で「pxeブート」で検索すると4番目に出てくる記事です。 競合となる記事も少なく、一方で対象読者もものすごい広いわけではないため、ニッチトップ的な位置を獲得できているといえます。この手の記事は一見すると ターゲットが狭くパッとしない記事かな と思ってしまいますが、そこが逆にニッチトップの感触といえます。たとえターゲットが少なくても競合が少なくても十分に PV を稼ぎます。またニッチトップ記事は競合が出にくいことも強みの一つです。

パフォーマンスの良いニッチトップ記事の要件は大きくは3点です。

  1. トピックがニッチで競合記事が少ないこと(「やってみた系」記事が少ないこと)
  2. 取り扱う技術トピックのレイヤーが低いものであること(インフラや、ミドルウェアなどは寿命が長いため資産になる)
  3. 最初から高度な内容を書かずに、その技術の呼び水的な内容であること

3番目が特に重要です。あまりに高度な内容にしすぎると対象読者をより一層狭めますから程よくエントリー向けにしておくのが吉です。これら3条件を満たし且つ 読みが当たれば ニッチトップ記事は息長くあなたのブログにPVを齎してくれます。 参考までに先ほどの PXEブート の記事のアクセス推移を掲載しておきます。

「初めてのPXEブート」のアクセス推移
投稿してから徐々にアクセスを伸ばしています

見るとわかるように投稿直後はそこまでアクセスがありません。しかし、月を追うごとにじわじわ伸びていっていますね。ニッチトップ系記事は対象読者数が多いわけではないので、最初から大きなアクセスは期待できませんが、徐々に記事の内容が評価され検索結果で上位に入り初めてパフォーマンスが安定します。

同じような傾向はこちらの記事にも見られます。

英文の自然言語処理におススメ!お手軽なPolyglotを使ってみた。 - astamuse Lab

こちらの記事も「polyglot python」で一位になっています。アクセスは初月にちょっと伸ばして、翌月に底を舐めその後徐々に安定してパフォーマンスを出しています。

「英文の自然言語処理におススメ!お手軽なPolyglotを使ってみた。」のPV推移
初月は少しはねているものの、翌月一度凹んでそこから徐々にパフォーマンスが上がっている

ニッチトップ記事は書く前にある程度の調査が必要です。自身が持っている技術知識の中で先程の3条件に当てはまっているかどうか確認し、確信を得てから書くと良いでしょう。

PVを稼ぐ記事 その2 「持続性抜群のインフラ技術記事」

PVを稼ぐ記事のその2は 「インフラ技術記事」 です。

正確に言うと、インフラなど、劇的なパラダイム・シフトやバージョンアップが起きにくいレイヤーの技術記事 です。このパターンには 言語のリファレンス記事など(ex. https://note.nkmk.me/ )なども含みます 。当ブログの例で言えばこちら記事などがわかりやすい例です。

Linuxでユーザアカウントを無効化するエトセトラ - astamuse Lab

この手の記事は対象となる技術・ノウハウ自体の寿命が長いため底堅い PV を稼いでくれます。一事例ではありますが、当ブログの2020年11月のアクセスを見てみますと Linux 関連の記事が3本上位にランクインしています(★太字)。

タイトル 投稿月 月のPVに占める割合
初めてのPXEブート 2018/10 8.03%
Pythonでも型を明記しよう 2019/09 7.21%
★Linuxでinodeが枯渇した場合にどうやって調査するか 2018/03 7.11%
Vue.jsでAccordionを作ってみるく 2018/10 5.92%
仕事用ディスプレイを 4K で作業してみました 2019/02 4.75%
英文の自然言語処理におススメ!お手軽なPolyglotを使ってみた。 2017/07 4.62%
★/etc/hosts で同じホスト名に違うIPアドレスを設定したらどうなるか 2018/11 4.34%
★Linuxでユーザアカウントを無効化するエトセトラ 2019/03 3.08%
デザイン採用担当はポートフォリオで何を見ているか、何が見えているか? 2016/11 2.46%
特異値分解と行列の低ランク近似 2017/06 2.26%
Python が Cloud Functionsで使えるようになったので試してみました 2018/10 2.07%

いずれも一年以上前の投稿にも関わらず安定的に PV を叩いています。こちらも参考までにPV数をグラフにすると下図のような推移です。

インフラ系記事のPV推移
インフラ系記事のPVは安定的なパフォーマンス

長く継続的にアクセスを稼いでますね。(@namikawa)曰く「 Ubuntu の OS 調べる記事が結構なPVを稼いでくれる 」とのことで cat /etc/os-release って打つだけの記事といえどもインフラの記事というのは侮れないのが実際のところです。ブログを伸ばすならこうした底堅い隙間ネタを見つける能力が大事なのでしょう。ただ一方で会社ブログで cat /etc/os-release の記事を書くかどうかはブログの方針にもよりますから留意が必要です。

PVを稼ぐ記事 その3 「数理系・統計処理系記事(但し平易でコード付き)」

PVを稼ぐ記事のその3は 「数理系・統計処理系記事(但し平易でコード付き)」 です。ちょっと分かりにくいですが例えばこういう記事です。

数理モデルを用いる実装というのは慣れてない方には敷居が高いため入門記事ニーズはそれなりあります。そういう方たちにとってこの手の記事は

  1. 数理モデルの理解を助けてくれる
  2. しかもその実装の仕方を教えてくれる

という点で非常に重宝されます(それがいいかどうかは置いておきますが)。先の紹介した記事だと特異値分解はちょっと専門的で、コレスポンデンス分析はロジックの言及がちょっと薄い感もありますが、この感触でも PV は継続的に上げてくれます。 誰しも TF-IDF を実装しようとしたときに 丁度いい感じにわかりやすくまとまった、しかも実装コードも書いてくれている投稿 を探した経験はあるかと思いますが、そんなニーズにフィットするような記事が良いのかと思います。

PVを稼ぐ記事 その4 「その他ライフハック系・ノウハウ記事」

最後にご紹介するPVを稼ぐ記事のその3は「その他ライフハック系・ノウハウ記事」です。

まぁ、これは catch-all 的な分類ではありますが、このような記事です。

いずれも開発・デザインの傍流の記事ですが非常にPVが高い記事です。 この手の記事のアクセスパターンは2つに大別されます。

  1. 最初にバズって先3~5年分のPVを稼ぎに行くケース
  2. 持続的にPVを稼ぎに行くケース

先の 4Kディスプレイの記事は公開一週間で万単位のPVを叩きまして今も持続的にPVがあります。ご存知の通りライフハック系はバズるポテンシャルが高く、タイトル次第では驚くくらいのパフォーマンスを発揮します。しかもそれが瞬間風速でとどまらず初月過ぎても数字を出し続けるのです。

一方、ノウハウ記事も年間で1万程度の PV を数年にわたって出しますので侮れません。勿論そういう記事は踏み込んだノウハウ記事に限られますが、ブログの他の記事との相性が良ければ十分資産になり得るものです。また、後ほど紹介しますが、うまくリバイブできればより長い寿命を獲得できるでしょう。

番外編:その他の記事パターン

せっかくなので当ブログで掲載されている他のパターンもご紹介します。

思索系記事

思索系記事は寿命が1年程度という感触です。最初にバズると長寿になりますがそれでも2年くらいです。

思索系記事は作成コストが高いため皆さん投稿したがりません。ただ、そういった記事だからこそ、また一人の人間が仕事を通して培った思考が蓄積された記事だからこそ、共感尊敬 を集めることができるのも特徴です。 そう考えると、この手の記事をそれなりに広げるならば、予め業界のオピニオンリーダーに retweet されたり シェアされるように仕込んでおく、というような戦術は良い戦術と言えます。

トレンド技術系記事

トレンド技術記事は対象となる技術にもよりますが大体2年程度の寿命という印象です。当ブログには PlayFramework2.5 ~ 2.6 の記事がありますが PV 推移で見ると2年程度でアクセスが落ちています。

f:id:astamuse:20210106183426p:plain

Express.js の記事も概ねそれくらいの寿命です。フロントエンドなのでもう少し短いかと思いましたがそうでもないですね(フロントエンドの技術が落ち着いてきたのでしょう)。

f:id:astamuse:20210106183817p:plain

技術ブログなので、こういった記事は勿論あって然るべきですがトレンド技術記事の寿命が2年ということは頭に入れておいて良いかと思います。

タイトルと SEO は超重要

と、色々申しましたが、そもそも論として記事で PV を稼ぐには タイトル が超重要であることはここで強く申し上げておきます。バズるようにするのか長く普遍的に利用される記事にするのか、こういった 記事の狙い によってワーディングは変わるものの、いずれせよ最低限 伝わるタイトル がなければ SNS でも Google の検索結果でもクリックを誘引することはないでしょう。ぜひ一度、自身の書いた記事のタイトルを見直して下記の3つを考えてみてください。

  1. タイトルが記事の内容を表しているか
  2. タイトルが興味を惹く様になっているか(これが凄まじく奥深いですが)
  3. どういう検索ワードで検索してほしいかを考え、そのワードの検索結果にこのタイトルがあったらクリックするか

もし見直してイマイチいけてないタイトルだったら修正しておきましょう。

また、改めて記事の内容を確認して狙っている読者とキーワードのフィッティング、密度などを確認されると良いと思います。

最後に

最後にこの分析データを Google Analytics API から取得するスクリプトを置いておきます。かなりざっと書いてしまったのでロギングとか例外処理はありませんが、参考にはなるかと思います。

# -*- coding:utf-8 -*-

from apiclient.discovery import build
from oauth2client.service_account import ServiceAccountCredentials
import os 
import re
import time
import datetime
import csv
from dateutil.relativedelta import relativedelta

SCOPES           = ['https://www.googleapis.com/auth/analytics.readonly']
KEY_FILE_PATH    = os.path.dirname(os.path.abspath(__file__)) + '/secret_key.json'
OUTPUT_FILE_PATH = os.path.dirname(os.path.abspath(__file__)) + '/access_data.csv'
VIEW_ID          = 'xxxxxxxxx'

def initialize_analyticsreporting():
  """Initializes an Analytics Reporting API V4 service object.

  Returns:
    An authorized Analytics Reporting API V4 service object.
  """
  credentials = ServiceAccountCredentials.from_json_keyfile_name(
      KEY_FILE_PATH, SCOPES)

  # Build the service object.
  analytics = build('analyticsreporting', 'v4', credentials=credentials)

  return analytics

def fetch_blog_post_url(analytics) -> list : 
    """
    GAで計測されているページのURLとタイトルを返します
    """
    start_date = "2015-01-01"
    end_date   = "today"

    result = analytics.reports().batchGet(
        body={
          'reportRequests': [
            {
                'viewId'     : VIEW_ID,
                'dateRanges' : [{'startDate': start_date, 'endDate': end_date}],
                'metrics'    : [{'expression' : 'ga:pageviews'}],
                'dimensions' : [{'name' : 'ga:pagePath'},
                                {'name' : 'ga:pageTitle'}],
                'orderBys'   : [{'fieldName' : 'ga:pageviews', 'sortOrder' : 'DESCENDING'}]
            }
          ]
        }
    ).execute()
    posts_data = []
    for report in result.get('reports', []):
        for row in report.get('data', {}).get('rows', []):
            path  = row['dimensions'][0]
            title = row['dimensions'][1]

            if re.match(r"^/entry/", path) and 'Entry is not found' not in title:           
                posts_data.append({
                    'path'   : path
                  , 'title' : title
                })
    return posts_data



def fetch_post_access_data(path, analytics) -> dict:
    """
    対象URLの週次のアクセス数を取得します
    """
    start_date = "YYYY-MM-DD" # 適宜書き換えてください(計測したい最初の月)
    end_date   = "today"

    result = analytics.reports().batchGet(
        body={
          'reportRequests': [
            {
                'viewId'     : VIEW_ID,
                'dateRanges' : [{'startDate': start_date, 'endDate': end_date}],
                'metrics'    : [{'expression' : 'ga:pageviews'}],
                'dimensions' : [{'name' : 'ga:pagePath' },
                                {'name' : 'ga:yearMonth'}],
                "dimensionFilterClauses" : [{
                    "filters" : [
                        {
                            "dimensionName" : 'ga:pagePath',
                            "operator"      : 'REGEXP',
                            "expressions"   : ["^" + path]
                        }
                    ]
                }],
                'orderBys'   : [{'fieldName' : 'ga:yearMonth', 'sortOrder' : 'ASCENDING'}]
            }
          ]
        }
    ).execute()

    access_data = {}
    for report in result.get('reports', []):
        rows            = report.get('data', {}).get('rows', [])
        if len(rows) == 0 :
            continue
        
        access_month_dt = datetime.datetime.strptime(rows[0]['dimensions'][1], '%Y%m')
        today           = datetime.datetime.now()

        # アクセスがない月は参照すべきデータがAPIのレスポンス内にないので予めデータの受け皿を作っておく
        index = 0
        while access_month_dt < today:
            access_data[access_month_dt.strftime('%Y/%m')] = { 'index' : index, 'page_view' : 0 }

            access_month_dt += relativedelta(months=1)
            index += 1

        # 改めてデータを走査
        for row in rows:
            year_month_dt = datetime.datetime.strptime(row['dimensions'][1], '%Y%m')
            year_month    = year_month_dt.strftime('%Y/%m')
            page_view     = int(row['metrics'][0]['values'][0])
            access_data[year_month]['page_view'] += page_view

    return access_data



def main():
    ofp    = open(OUTPUT_FILE, 'w', encoding='utf-8', newline="")
    writer = csv.writer(ofp, delimiter=",", quoting=csv.QUOTE_ALL)

    analytics  = initialize_analyticsreporting()
    posts_data = fetch_blog_post_url(analytics)

    data_length = len(posts_data)

    for i, post_data in enumerate(posts_data):

        time.sleep(3)
        path  = post_data['path']
        title = post_data['title']

        print("{0}/{1}    {2}".format(i, data_length, path))

        access_data = fetch_post_access_data(post_data['path'], analytics)
        for year_month, data in access_data.items():
            writer.writerow([data['index'], path, title, year_month, data['page_view']])


if __name__ == '__main__':
    main()

では、本日も最後までお読みいただきありがとうございました。

例によって当社では一緒にサービス開発してくれるエンジニア・デザイナー・ディレクターを募集しております。カジュアル面談も随時行っておりますので、「ちょっと話聞きたい」という方は、このブログのサイドバー下にあるアドレスか@YojiShirakiにDMいただければと思います。採用サイトもありますので下の水色のバナーから是非どうぞ!

@YojiShirakiの過去記事)

名寄せの仕組み

この記事は 自然言語処理 Advent Calendar 2020 の25日目の記事です。

こんにちは、rinoguchi です。今年の4月に こちらの記事 を書いて以来、半年ぶりの投稿になります。
当社では、特許・研究課題・論文など多くの知的財産データを保持しています。これらのデータを活用するには、データに含まれる同一組織・同一人物に対して同一IDを付与してデータをグルーピングすることが必要であり、この作業のことを名寄せと呼んでいます。
今回はこの名寄せの仕組みについて紹介したいと思います。

大まかな処理フロー

当社では名寄せ処理を、まずそれぞれのデータソース(例えば特許や論文など)の中で実行し、次に異なるデータソース間で実行することで、最終的に組織ID・人物IDに対して特許・研究課題・論文などを紐づけたデータを生成しています。
とはいえ、データソース内名寄せもデータソース間名寄せも仕組みとしては同じで、以下の4つのステージで構成されています。

f:id:astamuse:20201222215021p:plain

ここからは4つのステージについて、特定のデータソース内の人物名寄せを行うイメージで、処理内容を説明していきます。

1. クレンジング

クレンジングステージでは、生データに含まれる組織名や人物名を綺麗にして、プログラムで比較可能な状態にしていきます。

まずは、以下のような形で生データを候補データとして取り込みます。候補IDは、例えば特許の場合「出願番号×人物名」の組合せに対して一意になるように採番した値です。

候補ID 生データID 人物名(英) 人物名(ローカル)
c01 r01 TAKAHASHI ICHIRO 高橋 一郎
c11 r01 Yamada Jiro 山田 二郎
c02 r02 Pf. TAKAHASHI ICHIRO 高梁 一郎
c03 r03 Takahasi Ichiro
c04 r04 髙橋一郎

クレンジングステージでは、例えば以下のような変換処理を地道に行っていきます。

  • 名称の単純正規化(半角化、大文字化、不要記号などの除去)
  • 不要文字除去(部署名、役職名など)
  • 日英判断(日本語名称欄に英語が入っていたら英語名称欄に移動)
  • 姓名分割(わかち書き)
  • 英語翻訳(社内で保持している辞書およびGoogle Translate API)
  • エイリアス変換(組織のみ。社内マスタおよび公開データを利用)

クレンジング後のデータは以下のような感じになりました。ここまでくれば、人物名(英)を元に同一人物の可能性が高い候補IDをグルーピングできそうです。

候補ID 生データID 人物名(英) 人物名(日) 処理内容
c01 r01 TAKAHASHI ICHIRO 高橋 一郎 -
c11 r01 YAMADA JIRO 山田 二郎 大文字化
c02 r02 TAKAHASHI ICHIRO 高梁 一郎 不要文字除去
c03 r03 TAKAHASI ICHIRO 日/英判断、半角化
c04 r04 TAKAHASHI ICHIRO 髙橋 一郎 姓名分割、翻訳

2. メタ情報追加

クレンジングステージで名称が綺麗になったら、次は候補IDに対してメタ情報を追加していきます。
メタ情報は様々なデータソース(特許や論文など)を同じデータモデルで扱えるように、ある程度抽象化した名前(開始日、タイトルなど)にしてあります。

単純追加

生データに紐づく属性情報は、生データIDをキーに検索・抽出して候補IDに付加します。
例えば、共同研究者や組織名はそれだけである程度同一と判断できる非常に重要なメタ情報ですし、開始日もあまりに離れている場合は別人の可能性が高まりますので重要です。属性情報自体は数多くあるのでその中から重要そうなものをピックアップして取り込んでいきます。

候補ID 生データID 人物名(英) 共同研究者 組織名 開始日 タイトル ...
c01 r01 TAKAHASHI ICHIRO [YAMADA JIRO] A会社 2020-01-01 〇〇に関する研究 ...
c11 r01 YAMADA JIRO [TAKAHASHI ICHIRO] B会社 2020-01-01 〇〇に関する研究 ...
c02 r02 TAKAHASHI ICHIRO [ ] A会社 2019-01-01 ××技術について ...
c03 r03 TAKAHASI ICHIRO [YAMADA JIRO] A会社 2008-01-01 △△について ...
c04 r04 TAKAHASHI ICHIRO [ ] C会社 1995-01-01 □□について ...

タイトル抽出・ベクトル化

タイトルや本文も人間が判断する時にはかなり参考になりますが、そのままでは機械的には比較できませんので、タイトルはキーワード抽出、本文はベクトル化しました。

  • 事前処理(小文字化、stopword除去、ステミング、単語分割)
  • doc2bowを使ってBoW化(コーパス化)
  • TF-IDF(gensimのTfidfModel)で、単語の特徴度を計算
    • => 特徴度が一定以上の単語をタイトルキーワードとする
  • LSI(gensimのLsiModel)で、300次元に圧縮
    • => 本文ベクトルとする

自分は自然言語処理に詳しくないのですが、社内の自然言語処理の専門家である じんさん に相談したところ、特許や論文などのデータは特徴的な単語が使われていることが多く、それだけでかなり同一性を判断できるので、TF-IDFを用いるのは理にかなってるというアドバイスをもらいました。
実際のデータで検証したところ、タイトルキーワードや本文ベクトルから同一性が高いと判断したものは、人間の判断をかなりトレースできていることも分かりました。

少しシステム的なことを言及すると、このTF-IDFおよびLSIを用いたベクトル化の処理は、何も考えずに数億件のデータに対して適用すると計算量が大きくなりすぎますので、システム的には以下の2点を工夫をしました。

  1. 同一可能性が高いデータをグルーピングして、そのグループの中でベクトル化する
    • イメージ的には「人物名(英)のイニシャルが同じ」ものをグルーピングする感じ
    • Nが小さすぎるとベクトルが偏るので、グループを足し合わせて N=50000 ぐらいになるように調整
  2. グループ毎のベクトル化の処理を、Apache Spark 環境のフルマネージドサービスであるDataproc上で並列実行する

これにより、1億件を数日で処理できるようになりました。

3. 名寄せ

名寄せステージが全体の本丸的な位置付けなのですが、同一の可能性が高い候補同士をつなぎ合わせる処理を行います。

f:id:astamuse:20201223092642p:plain

同一の可能性がある候補同士を連結(組合せを列挙)

これは、同一の可能性がある候補IDの組合せを抽出するという処理になります。後続の処理でこの組合せ一つ一つに対してスコアリングを行うのですが、その際の計算量を抑えるための処理になります。
やりたいことは「人物名(英)の編集距離が閾値以下 or イニシャルが一致」するような候補IDを組合せを抽出するだけなのですが、この組合せを抽出する処理自体がかなりの計算量になります。
n個の候補IDから2つを取り出す組合せの数の計算式は  _nC_2 なので、例えば1億件の候補IDが存在する場合、1億 × (1億 - 1) / 2= 約5000兆 回の計算(比較処理)が必要になります。
この計算量を現実的な時間で終わらせるため、大きく3つ試してみました。

  • BigQuery上でSQLで比較する【不採用】
    • => 1億件×1億件のcross join。CPUがオーバーフロー
  • グラフDB(neo4j)に突っ込んで、条件に一致するサブグラフを抽出する【不採用】
    • => 試しに15万ノード3億エッジぐらいで試したが応答なし
  • Dataprocを利用し、pysparkで分散処理【採用】
    • => 現実的な時間で結果が返る

ここでも結局、分散処理を導入して解決しました。
結果として、一番データ件数が多い特許のケースで、同一の可能性がある組合せの件数は数百億件になりました。

候補の組合せ(エッジ)に対してスコアリング

候補の組合せを列挙できたので、次は各組合せのスコア(同一可能性)を数値化していきます。

この部分は、データソースによっても利用できるメタ情報が異なり、後からメタ情報を追加するケースもありそうなので、比較ルール(使用するメタ情報と比較ロジックのセット)をプラグインのように差し込めるように設計しました。

ここで比較ルールは例えば以下のようなもので、これらの比較ルールは互いに依存関係はなく独立して計算可能にしてあります。

  • 比較ルール1: 本文ベクトルの類似度
  • 比較ルール2: 共同研究者の一致
  • 比較ルール3: 開始日の近さ

それぞれの比較ルールの処理結果が出たら、最後にこれらの数値を元に総合的なトータルスコアを算出します。

候補ID
FROM
候補ID
TO
比較ルール1
スコア
比較ルール2
スコア
... トータルスコア
c01 c02 0.51 0.45 ... 0.57
c01 c03 0.90 0.58 ... 0.67
c02 c03 0.20 0.49 ... 0.25

スコアを元に候補(ノード)を繋げてグルーピング

候補の組合せに対して同一可能性(=トータルスコア)を数値化することができたので、スコアを元に候補を繋いでいく処理(名寄せ)を行います。

この候補の繋がりは、グラフ理論における重み付きの無向グラフと考えると以下のように可視化することができます。候補がノード、候補組合せがエッジ、トータルスコアが重みになります。

f:id:astamuse:20201226185948p:plain

エッジを特定条件で繋いでいく処理は、グラフDBでサブグラフを抽出する処理にあたるのでグラフDBを導入しようかとも思ったのですが、pythonで実装しても数時間程度で完了したので、pythonで実装しました。

4. 名分け

当初「3. 名寄せ」で処理終了の予定だったのですが、実際にやってみると明らかに別人が紐づけられているケースがあることが分かりました。

例えば、以下のようなケースではc01とc02は漢字表記が異なり、c03は漢字表記がありません。

候補ID 人物名(英) 人物名(日) メタ情報
c01 TAKAHASHI ICHIRO 高橋 一郎 ...
c02 TAKAHASHI ICHIRO 高梁 一郎 ...
c03 TAKAHASHI ICHIRO ...

このケースでは、c01-c02 は別人と判断できますが、c01-c03c02-c03 はc03側に漢字表記が存在しないためその他のメタ情報だけで判断することになり、結果として同一と判断されることがあります。そうなると、c03を経由して c01-c03-c02 が同一人物として繋がってしまうようなことが発生します。

というわけで、明らかに別人同士が繋がっている場合にそれを別IDに分ける名分け処理が必要になります。

この名分け処理も、グラフ理論のグラフカット問題として捉えると取り組みやすかったです(これも前述のじんさんに教えてもらいました。感謝!)。
具体的にはトータルスコアが閾値以下のエッジの両端のノードを別グラフに分ける処理になり、最大フロー最小カット定理を導入して、カットするエッジの重みが最小になるように最小カットをしてあげることで実現できました。

f:id:astamuse:20201226192616p:plain

ちなみに、この処理pythonで実装してみたところ異常に時間がかかったので、C++ で実装し直しました。pybind11を使うと C++ で実装した関数をpythonから呼び出せるので便利です。

最終的にグラフ上で繋がっている候補IDに対して同一の人物IDや組織IDを採番することで名寄せ完了になります。

さいごに

今回、当社の名寄せの仕組みについて紹介してみました。説明の都合上実態よりシンプルに書いてはありますが、実際に動いている仕組みなので、ある程度は参考になるんじゃないかと思っています。

サンプル母集団での精度は、F値で0.90〜0.99とデータソースによって異なるものの当初の目標値(F値0.9)は達成できました。とはいえ、教師データを作りきれてない本番データではこれを下回る可能性が高く、今後も改善を続けていく必要があります。

弊社は、一緒にデータ整備を手伝ってくれる方を募集中です!自分も入社してすぐ名寄せという結構コアな領域を任せてもらえたぐらい人手が全く足りてない状況ですので、興味がある方はぜひ以下よりご応募ください!心よりお待ちしてます。

recruit.astamuse.co.jp

[参考] 機械学習を導入してない理由

今回の名寄せの処理は、計算量の問題を解決できれば普通に機械学習にとてもマッチするように思えます。実際、導入するかかなり悩みました。しかし、今回は以下の理由から導入を見送りました。

  • ブラックボックスになってしまう
    • データを利用するお客様に対して、データがそうなっている理由を明確な説明できなくなる
  • 教師データ作成が非常に困難で教師データを100%信じられない
    • 人間が判断しても人によって同一人物かどうかの判断に迷うケースは多く、特に同姓同名に対する候補IDが100件を超えだすと普通に見落としも多くなり不正な教師データができてしまう
  • 明確に別人扱いにしたいケースもある

とはいえ、部分的に機械学習を導入することでより正解に近づける可能性もあり、今後導入する可能性もあると思っています。

データ分析ことはじめ 〜はじめてのデータ分析やってみたよ〜

f:id:astamuse:20201217033839j:plain

ご挨拶

どうもお久しぶりです、gucciです。
気づけばもう12月も半ば…今年は本当に色々なことがあった年でしたね。

新型コロナによってこれまでの日常が一変し、当たり前だったものが当たり前でなくなるという本当に大変な年だったと思います。

私の好きなプロ野球ももちろん多大な影響を受けて、シーズンの開始が遅れ、無観客試合や手拍子での応援など、これまでとは異なるシーズンでしたが、何はともあれ無事にシーズンが終わることができたのは本当に素晴らしいことだと思います。

データエンジニアとしてやってきて

さて、そんな中で私はデータエンジニアになり2年が経ちました。
(入社して1年弱アプリエンジニア〜その後データエンジニアにジョブチェンジ)

弊社はとにかく様々なデータを集めています。
技術情報と他のデータを組み合わせたり、新しい分析手法を用いたり、独自の切り口で分類を行なったりすることで、弊社にしかできないソリューションを提供しております。

非常に優秀なデータアナリストがおりますので、分析するためのデータを我々データエンジニアが整備していくといった塩梅でございます。

これまでデータを色々な形で料理できるようにあれこれ設計・整備して参りましたが、おやおや、データを実際に使う側にも少し興味が湧いてきました。

そこで今回は、
データ分析ことはじめ 〜はじめてのデータ分析やってみたよ〜
というお題でお送りしたいと思います。

ちなみに、本当に初歩の初歩のことしかしないので、今回の内容はどちらかといえば非エンジニア向けのライトな内容です。
「データで分析っていってもまったく想像がつかない!」
「プログラミングでデータを使うと何ができるの?」
「野球が好き!」
という方におすすめです。

そうだ、野球のデータを使ってみよう

さて、データの分析をやってみようかなと思ったものの何を題材にして良いのかさっぱりわかりません。
悩み抜いた末に、日頃からこっそり集めていたプロ野球のデータを活用してみることにしました。
それでは簡単クッキング!!

使うもの

  • プロ野球のデータ: csvファイル
  • 実行環境: JupyterLab
  • データごにょごにょ: Pandas
  • 可視化ライブラリ: Plotly Express

プロ野球のデータ

いつもお世話になっているサイトがこちらの 「プロ野球データFREAK」さんです!!
こちらのサイトから「打者成績」「選手一覧」のデータを拝借させていただきました。
いつもありがとうございます。

実行環境

今回 python で分析を行なっていきます。
JupyterLab は Jupyter Notebook の進化版で、ブラウザ上で動作するプログラムの対話型実行環境です。
簡単に実行結果が確認でき、またグラフなどの描画も画面上でできるのでデータ分析には欠かせないツールと言えそうです。

データごにょごにょ

データ分析といえば Pandas (実はあまり使ったことがなかった)。
Dataframe という「表」でデータを扱うことができますので、とりあえずこいつでやっていきましょう。

可視化ライブラリ

今回はいわゆる有名な Matplotlib でなく Plotly Express というのを使っていきます。

plotly.com

弊社自慢の若手超優秀機械学習エンジニアのNくんが以前紹介されていたので、使ってみたくて選びました。
元々は Plotly というオープンソースのデータ分析・グラフ可視化ツールがあって、それのラッパーライブラリが Plotly Express だそうです。
より簡単にインタラクティブな描画ができるという優れモノです。
3Dやアニメーションも使えるみたいなのでなんだかとっても良さげ。
ただ、JupyterLab で描画すると最初は真っ白で何も表示されなくてビビります。
ちゃんとチュートリアルを読まないとですね。

plotly.com

給料泥棒を探せ

それでは、さっそくやっていきましょう。

  • 2020年の年俸
  • 2020年の成績

これらを組み合わせて可視化することで、給料泥棒お金を貰っている割にあまり結果を出せていないのは誰か、というのを調べちゃいましょう。
今回は打者の指標としてはOPSを用います。

OPS(オプス、オーピーエス)は On-base plus slugging の略であり、野球において打者を評価する指標の1つ。出塁率と長打率とを足し合わせた値である。

いっぱい塁に出たり長打を打てる人が打者として優れているよね、って指標です。
1を超えたら本当にすごい。

ここからは簡単にコードのご紹介!!(いい感じにパスとか汲んでくださいませ)

必要なライブラリの読み込み

import plotly.express as px
import glob
import pandas as pd
import re

CSVデータ読み込み

# 2020年の打者成績一覧を読み込み
df_all_batter_stat_2020 = pd.read_csv('{適切なパス}/Baseball/Batting/2020_stats.csv', sep = ',')

# 2020年の各球団の選手一覧を読み込み
# 「2020_{チーム名}.csv」で格納してある
dir_path = '{適切なパス}/Baseball/Player/'
all_files = glob.glob(dir_path + "2020*.csv")

# 一つ一つのデータを読み込んで dataframe に追加していく(その際チーム名も追加)
df_all_player = pd.DataFrame()
for filename in all_files:
    df = pd.read_csv(filename, sep=',')
    team_name = re.sub('\d+_', '', filename.split('/')[-1].replace('.csv', ''))
    df['チーム'] = team_name
    df_all_player = pd.concat([df_all_player, df]).reset_index(drop=True)

打者成績一覧と選手一覧とがっちゃんこ

df_all_batter_data_merged = \
    pd.merge(df_all_batter_stat_2020, df_all_player, how='inner', on=['選手名', 'チーム'])

'年俸(推定)' の文字列を'年俸(万円)'の数値に変換
OPSを算出して dataframe に追加

df_all_batter_data_merged['年俸(万円)'] = \
    df_all_batter_data_merged['年俸(推定)'].str.replace(',', '').str.replace('万円', '').astype(int)
df_all_batter_data_merged['OPS'] = \
    df_all_batter_data_merged['長打率'] + df_all_batter_data_merged['出塁率']

全選手表示すると大変なことになるので、
『規定打席を超えた人(372打席)または年俸が1億円より多い人』を抽出

df_all_batter_data_merged_over_10000 = \
    df_all_batter_data_merged[(df_all_batter_data_merged['年俸(万円)'] > 10000) | (df_all_batter_data_merged['打席数'] > 372)]

いざ、散布図を表示

fig = px.scatter(
    df_all_batter_data_merged_over_10000,
    x='OPS',
    y='年俸(万円)',
    text='選手名',
    color='チーム',
    size_max=30,
    height=500,
    width=800,
    size='本塁打',
    color_discrete_map={
        '巨人': '#F97709',
        '阪神': '#ffff00',
        '広島': '#FF0000',
        '中日': '#00008b',
        'DeNA': '#00bfff',
        'ヤクルト': '#7cfc00',
        'ソフトバンク': '#f9ca00',
        'オリックス': '#b08f32',
        '日本ハム': '#02518c',
        'ロッテ': '#221815',
        '西武': '#102961',
        '楽天': '#85010f'
    }
)

fig.show()

その結果がこちら↓↓

いかがでしょう!!
この描画されたグラフ、マウスホバーで情報がでたりドラッグでズームインとかできちゃうんです!!
すごいお便利。
ちなみに、円の大きさが本塁打の数を表しています。
本当はもっと大きく出したかったのですが、サイトのページサイズの関係でこの形になりました…。
こちらの画像の方が見やすいかもしれません。

f:id:astamuse:20201217023224p:plain
2020年俸/2020打者OPS

この散布図を見るだけで、

  • 図右上:期待通りゾーン
    • 柳田えぐい(一番年俸をもらっていて一番OPSが高い。すばらしい。)
  • 図左上:年俸の割にOPSが高くないゾーン
    • バレンティンがちょっと…年俸の割に…残念…
  • 図右下:年俸の割によくやっているゾーン
    • 村上くんは年俸の割にものすごいOPS

といったことが簡単にわかってしまいました。データを可視化するってのはすごいことですね。
これまでの貢献やOPS以外での活躍というのももちろんありますので一概には言えませんが、一つの見方としては面白いですね。
もうすっかり分析した気分になれちゃいます。

きちんとお給料もらってる?

さぁ乗ってきたところで、今度は活躍に応じてきちんと年俸が支払われているのかチェックしてみましょう。
前述の検証では、2020年の年俸と2020年の成績を使いましたが、
本来年俸というのは結果に対して「よう頑張った!!次も期待してるで!!」の側面が強い(成果)と思いますので、ためしに1年ずらして計算してみましょう。

  • 2015〜2019年の成績の平均
  • 2016〜2020年の年俸の平均

これらを使ってみます!! 基本的にはほぼ同じコードですので、新しいとこだけ書いておきます!!

集約して平均をだす

grouped_batter_detail = df_all_batter.groupby(['選手名', 'チーム'], as_index=False)
grouped_batter_detail_avg = grouped_batter_detail.mean()

新しいのはこの子ぐらいでした。
5年に増やしたことによりさらに選手がごちゃくつので、
『平均400打席を超えた人または平均年俸が2億円より多い人』に条件を変更して・・・抽出!!

その結果がこちら↓↓

いかがでしょう!!
いちおおっきいのも貼っておきます!!

f:id:astamuse:20201217025510p:plain
5年平均での図

この散布図からは、

  • 図右下の広島の丸選手が、図右上の巨人に移籍してしまうのも仕方がないかも…
  • 近似曲線をイメージすると、やはり全体的にソフトバンクと巨人はそれよりも上にいそうな気がする…
  • 気のせいかもしれませんが、中日は少し給料が低めなイメージが…

といったようなことがパッと思いつきますね!!
(個人の考えなので、ご気分を害された方がいらっしゃいましたら申し訳ございません。)

まとめ

いかがだったでしょうか。
大した分析はできていませんが、データを元にパッと可視化するだけで新たな気付きや発見があるというのが体験できたかと思います!!
今回はほんの一部の可視化〜分析でしたが、機械学習とかも絡めて予測できたらさらに幅が広がりそうですね!!

最後に、アスタミューゼではアプリエンジニア、デザイナー、プロダクトマネージャー、データエンジニア、機械学習エンジニアなどなど絶賛大募集中です!!
どしどしご応募ください!!お待ちしております!!

最後までお読みいただき、ありがとうございました!!

Copyright © astamuse company, ltd. all rights reserved.