AIが見つけた、埋もれたQiita良記事100選

1pt   2018-07-12 05:58
IT技術情報局

背景

Qiita殿堂入り記事、と、7つの「驚愕」

良い記事なんだけどなかなかいいねがつかなかった記事を、
AIによって発掘したいという試み
または、機械によって「いいね」かどうか判断させたり、
何いいねになる記事なのか、予測することは可能なのか?という問題を考えてみたい。

以下の2つの記事の中でも少し予告していた、
一番難しそうな内容に挑戦してみる。

Qiitaの記事データの取得と、全体感について記載。
既にいいねを多数集めている「良記事」を「殿堂入り」として月ごとにまとめ。

直近の記事データから、傾向分析を実施し、
様々な知見や気づきを得ることが出来た。

上の記事を読んでいない人へのまとめ。前回までのあらすじ

  • Qiitaのデータを分析したよ。
    • 2016年4月の記事は、2018年5月に比べて3倍いいねが貰えたよ(仕様変更影響大きいね)
    • ⇒分析や比較は、出来るだけ投稿日の近い記事同士が望ましいね。
  • 月ごとに、「いいね」が高い記事を殿堂入りとしてランキング化したよ。
    • TOP30記事=「0.38%」で、その月の投稿に対するいいねの「43.7%」 も占めるよ。
    • どの記事を読んでも面白いから見てね。
  • データを分析した結果、いろいろ面白いことが分かったよ。一部を紹介するね。
    • 「いいね」の平均値は8.02。半分以上は0~2。めっちゃ偏っているね。
    • 人気のタグが「いいね」が多いわけではなくて、「機械学習」とかが多いんだなあ。
    • 「タグ」と「いいね」の関係表や、タグ同士の関連を図示するグラフを作ったよ。

今回やりたいこと

最初に記載した考察の結果、「いいね」が多い⇒良記事、と仮定した。
殿堂入り」に集めた結果はどれも良記事で、仮定は正しかった。

しかし、「いいね」が少ない⇒良記事ではない、は成り立たない
7つの「驚愕」の結果を見ても明らかなように、「いいね」はトレンド次第で大きく偏る。
そのため、人の目にあまり触れられていなかった、不遇な良記事が多数眠っているのではないか?

自然言語処理/機械学習を駆使して、海の底に眠る良記事を発掘出来ないだろうか!?

  • 試した結果こんなの出ました、で終わりにしない。
  • 確かに良記事っぽい、ところまで、チューニングを行う。
  • チューニングには、極力、人間(私)の主観的チューニングを入れない。
    • (「初心者」タグは面白い、とか決め打ちではなく、数値がN以上を取る、などで調整)
本投稿の内容
  • 結論/成果 = AIによって「良記事」と判断された100記事を公開
    • 「良記事」レベルを判定するプログラムが出来た。
    • 「文章的な記事」に偏ってしまった感はあるものの、
    • 良記事なのにいいねが少ないものを抽出できたような気がする。
      • 良記事かどうか?は個々人の判断によるところが大きいため、
      • 成果と手法を公開し、フィードバックを募ることにする。
  • 試行錯誤の過程や、処理のコード/ノウハウを公開
  • 試行錯誤① Doc2Vec
    • テキストをベクトル化/数値に変換する技術。
    • 良記事との距離が近い記事=良記事、という仮定は成り立つのか?
  • 試行錯誤② クラスタリング
    • 良記事と、良記事ではない(失礼)は、クラスタリングによって分類されるか?
  • 試行錯誤③ 最終的には上記組み合わせ(オリジナルロジック)
    • 結果に至るまでの最終考察と手法。
  • コードの実行環境は全て、Windows10 + Python3 +JupyterNotebook を前提。
当初仮説

良記事の類似記事は良記事?という仮説(Doc2Vec活用)

Doc2Vecを使うと、文章をベクトル化して表現することが出来る。
(Word2Vecを応用して、文章に適用出来るようにしたようなもの)
詳細は、ググれば出てくるので省略する。

この技術を使えば、ある記事に対して、「最も似ている記事はどれ?」
といった形で検索が出来る。
(ただし「文章」を扱うので、精度についてはお察しレベル)

埋もれている良記事=複数の良記事が近くに居る、と仮定すると、
ある記事に対して、類似記事のTOP10などを出して、
類似記事の平均いいね値が高い記事は、埋もれた良記事なのではないか?という仮説を立てた。

各種前提

実装の話に行く前に、いくつか今回の前提を記載する。

今回使用するデータは全て、2018年6月末ごろに取得した、
2018年05月に投稿された記事 ~ 2017年06月に投稿された記事
のデータ=79072記事について扱っており、
「いいね」の値についても、データ取得時点の値である。

Doc2Vecのモデルを作る方法

Doc2Vecを作るための前加工処理

Doc2Vecのモデルを作るためには、学習用のデータとして、
「文章の名前 (タイトルやタグ): 文章のテキストを単語リスト化したもの」
の形(TaggedDocument形式)に記事を加工する必要がある。

形態素解析はmecab&mecabの辞書(ipadic-neologd)を使用する。

以下のようにして加工する。

TaggedDocumentリストの作成 #品詞を限定した、タグドキュメントリストの生成 from gensim.models.doc2vec import Doc2Vec from gensim.models.doc2vec import TaggedDocument import MeCab #Mecabによる解析の実験:ipadic-neologdの辞書を指定している。 text = '日本語の自然言語処理は難しいよね。' tagger = MeCab.Tagger(r"-Ochasen -d .mecab-ipadic-neologd") result = tagger.parse(text) print(result) #タグドキュメントのリストを作る TaggedDocument_list=[] #投稿記事を、タグドキュメント化して、そのリストを作っていく。 #全記事対象にした。 for item_info in item_info_list: #投稿記事のURLと本文(body)を取り出す。 url_str=item_info[3] body=item_info[9] # ★ノーマイライズを入れる normalized_body=normalize_neologd(body) lines = tagger.parse(normalized_body).splitlines() words = [] for line in lines: chunks = line.split('t') # ★特定の品詞に限定して利用する。 if len(chunks) > 3 and (chunks[3].startswith('動詞') or chunks[3].startswith('形容詞') or (chunks[3].startswith('名詞') and not chunks[3].startswith('名詞-数'))): words.append(chunks[0]) #TaggedDocumentは、以下のような形式で生成する。モデル利用時には、tagsの値でこの文書を指定する。 #TaggedDocument_pair = TaggedDocument(words=['名詞', '動詞', '形容詞', '日本語', '処理', '難しい'], tags=['d1']) #タグは、URLにする。 TaggedDocument_pair = TaggedDocument(words=words, tags=[url_str] ) TaggedDocument_list.append(TaggedDocument_pair) print(len(TaggedDocument_list))

加工の時のポイントは2点ある。
当初はただの分かち書きで実施していて、生成したモデルの精度が少し悪かったために、
この2点の処理を入れたところ、Doc2Vecの精度が向上した。

  • 特定の品詞に絞って利用
  • ノーマライズ

特定の品詞に絞って利用するのは、
意味の薄い単語をあらかじめ消しておき、
文章中の重要な用語だけ扱うようにすることで精度を上げるため。

ノーマライズについては、以下の例がイメージしやすい。
全角のアルファベットの変換や全角スペースを削除or半角化したりして、
同じ意味の単語が、同じ表記になるように、揃えること。

ノーマライズの実行例 normalize_neologd("南アルプスの 天然水 Sparking Lemon レモン一絞り") # > '南アルプスの天然水Sparking Lemonレモン一絞り'

この関数は、mecab-ipadic-neologdのプロジェクトから
パクってきたコピーしてきただけのもので、コードは以下の通り。

ノーマライズ #https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja #ノーマライズ:詳細は↑のURLを参照 # encoding: utf8 from __future__ import unicode_literals import re import unicodedata def unicode_normalize(cls, s): pt = re.compile('([{}]+)'.format(cls)) def norm(c): return unicodedata.normalize('NFKC', c) if pt.match(c) else c s = ''.join(norm(x) for x in re.split(pt, s)) s = re.sub('-', '-', s) return s def remove_extra_spaces(s): s = re.sub('[  ]+', ' ', s) blocks = ''.join(('u4E00-u9FFF', # CJK UNIFIED IDEOGRAPHS 'u3040-u309F', # HIRAGANA 'u30A0-u30FF', # KATAKANA 'u3000-u303F', # CJK SYMBOLS AND PUNCTUATION 'uFF00-uFFEF' # HALFWIDTH AND FULLWIDTH FORMS )) basic_latin = 'u0000-u007F' def remove_space_between(cls1, cls2, s): p = re.compile('([{}]) ([{}])'.format(cls1, cls2)) while p.search(s): s = p.sub(r'', s) return s s = remove_space_between(blocks, blocks, s) s = remove_space_between(blocks, basic_latin, s) s = remove_space_between(basic_latin, blocks, s) return s def normalize_neologd(s): s = s.strip() s = unicode_normalize('0-9A-Za-z。-゚', s) def maketrans(f, t): return {ord(x): ord(y) for x, y in zip(f, t)} s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s) # normalize hyphens s = re.sub('[﹣-ー—―─━ー]+', 'ー', s) # normalize choonpus s = re.sub('[~∼∾〜〰~]', '', s) # remove tildes s = s.translate( maketrans('!"#$%&'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」', '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」')) s = remove_extra_spaces(s) s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s) # keep =,・,「,」 s = re.sub('[’]', ''', s) s = re.sub('[”]', '"', s) return s

Doc2Vecのモデルの生成

準備したTaggedDocument_listの学習を実行する。
オプションの指定の仕方によってかなり精度は変わる。

モデルの生成 %%time # 学習実行(パラメータを調整可能) # documents:学習データ(TaggedDocumentのリスト) # min_count=1:最低1回出現した単語を学習に使用する # dm=0:学習モデル=DBOW(デフォルトはdm=1:学習モデル=DM) #model = Doc2Vec(documents=TaggedDocument_list, min_count=10, dm=0) #iter:トレーニング反復回数 デフォルトは5? Doc2VecModel = Doc2Vec(documents=TaggedDocument_list, size=150, alpha=0.0015, sample=1e-4, min_count=10, workers=4, iter=30) # 学習したモデルを保存 Doc2VecModel.save('simple30_Doc2VecModel_150.model')

今回のポイントは、「iter」の指定。
トレーニング反復回数が少ないと、精度が出なかった。

実行回数については、もう少し複雑な計算をさせて、
動的に決めているコード例見受けられたが、
あまり凝ったことをやらなくても、何度かやってみて、
良さそうな値にすれば良いと思う。数十分程度で終わる。
当初、この設定をせずに実施していて精度が出なかった。
次に、凝ったコードを実施していたが、
最終的にiterの数を増やすだけで、凝ったコードと同等以上の結果になった。

ここで、「精度」と言っているのは、Doc2Vecの精度ではなく、
Word2Vecの結果の納得感のこと。
Doc2Vecモデルを上記の方法で作ると、合わせてWord2Vecモデルも生成される。
文章の類似度、を人の目で判断することは難しいが、
単語の類似度、であれば、納得感があるかどうか、で何となく分かる。
具体的には、次の、モデルの内容確認のコマンド結果を見て、
パラメータのチューニングを検討しているというわけ。

作成したモデルの内容確認

作成したDoc2Vecモデルで、どんな成果が得れるのか遊んでみる確認してみる。
以下のコードによって、
ある記事に似た記事を探したり
ある単語に似た単語を探したり(Word2Vec機能)が実施できる。

作成したモデルで遊んでみる model = Doc2VecModel #ファイルからロードする場合は以下: #model = Doc2Vec.load('simple30_Doc2VecModel_150.model') # ベクトル'd1'を表示(型はnumpy.ndarray) # print(model.docvecs['d1']) # 各文書と、最も類似度が高い文書を表示(デフォルト値:10個) print(model.docvecs.most_similar('https://qiita.com/youwht/items/f21325ff62603e8664e6', topn=3)) print(model.most_similar(positive=u"python", topn=10)) print(model.most_similar(positive=u"プログラム", topn=10)) print(model.most_similar(positive=u"開発", topn=10)) print(model.most_similar(positive=u"アプリ", topn=10)) print(model.most_similar(positive=u"機械学習", topn=10))

モデルの内容確認結果:

コード中ののURLが示すのは、対義語の記事。
「赤の他人」の対義語は「白い恋人」 これを自動生成したい物語
の記事に似ていると判断された、とってもとっても不名誉な記事TOP3は・・・
(※2018年5月~2017年6月の全79072記事中から選ばれたTOP3)

意外と似ている気がしないでもない・・・(失礼)。

また、Word2Vecモデルも合わせて生成されるので、
それぞれ似ている上位10単語を示してみる。

1位 py コード 構築 アプリケーション ディープラーニング
2位 Python スクリプト 開発中 画面 DeepLearning
3位 pip ソースコード アプリ開発 端末 自然言語処理
4位 numpy サンプル 整備 ブラウザ 深層
5位 pyhon ソース 導入 プロジェクト 強化学習
6位 bash インタプリタ 整える app データサイエンス
7位 conda 実行 開発者 サービス 人工知能
8位 jupyter notebook 実行ファイル 運用 デプロイ Deep Learning
9位 pytest 動作 プロダクション ログイン 教材
10位 venv 動く 環境 アイコン モデリング

pythonの5位の「pyhon」は、衝撃的なことに、
タグとしても利用されていて、2018年7月時点で、10投稿されている。
3回くらい見直してしまった

記事数をもっと増やせば、Word2Vecとしての精度はさらに上げることが出来る印象。
ある程度納得感のある類似検索結果であるため、
このモデルならば、文章の類似をやらせても上手くやってくれるだろう、と判断。

なお、コードブロックを考慮せずに実行しているのだが、
コードブロックは固まりになっているために、
自動的に上手く分かれてくれると思っていた通りの結果になった。
(例えば「if」の類義語は、else, return, continue, elseif・・・と続いた)

最初から良い結果を提示しているが、
上記で何点か上げたポイントを実行する前の結果は以下の通り。全然違う。ダメダメ。
こういったWord2Vecの結果を見ながら、どの納得のいく感じになるまで調整するのだ。

1位 py まず 向け アカウント 基礎
2位 ruby 検証 ツール 管理 学ぶ
3位 pip テスト ブラウザ プログラミング
4位 numpy 導入 整備 連携 ディープラーニング
5位 go 通り 構築 アプリケーション 初心者
6位 pwn 拡張 フレームワーク サービス 技術
7位 #!/ 行い 開発者 プロジェクト 知識
8位 sh とおり 無料 Slack 向け
9位 keras 実際 連携 ソース AI
10位 pandas 操作 チュートリアル 関連

ここまでの結果得られたモデルを使ってもいろいろ遊べるが、
今回の目的に戻って、
「良記事の類似記事は良記事」が成り立つのか考えてみよう。

良記事の類似記事のいいね数を調べる

既に認識されている良記事(今回は300いいね以上の記事と置く)に対して、
類似記事の平均いいね値を調べてみる。

良記事の類似記事の平均いいね数を見る #URLをキーにした辞書型にしておく。 url_key_dict={} for item_info in item_info_list: url_key = item_info[3] url_key_dict[url_key] = item_info print(len(url_key_dict)) topnNO=30 #全部の記事URL(key)に対して ruiji_iine_list=[] for kiji_url in url_key_dict.keys(): moto_kiji_iine = url_key_dict[kiji_url][0] #300いいね以上の記事に対しては、類似記事を取得する。 if moto_kiji_iine >299 : sim_kiji_list = model.docvecs.most_similar(kiji_url,topn=topnNO) iinesuu_list=[] iinesuu_list.append(moto_kiji_iine) for sim_kiji in sim_kiji_list: sim_kiji_url=sim_kiji[0] sim_kiji_iine=url_key_dict[sim_kiji_url][0] iinesuu_list.append(sim_kiji_iine) ruiji_iine_list.append(iinesuu_list) print(len(ruiji_iine_list)) total_val=0 moto_total_val=0 for iinesuu_list in ruiji_iine_list: moto_total_val+=iinesuu_list[0] for val in range(topnNO): total_val+=iinesuu_list[val+1] print(total_val/topnNO/len(ruiji_iine_list)) print(moto_total_val/len(ruiji_iine_list))

結果:
300いいね以上の記事(平均いいね値648.6)の、
各類似記事TOP30の、
平均いいね数は、「35.7」になった。

ポイントは、
直近1年のQiita記事分析で分かった7つの「驚愕」
の内容で見た通り、全記事の平均いいね値は「8」であるため、
「35.7」はかなり高い数字である。

しかも、上記のプログラムの「似ている」の範囲(TOPいくつまでか)を広げるほど、
平均値は下がっていくことも判明した。
良記事に似ている度が高いほど、平均値が高いということ。
さらに、平均300以上の記事ではなく、平均100以上の記事を対象にしても、
同様の結果になる。(平均値は少し下がる)
* 「300以上」「TOP10」⇒「42.9」
* 「300以上」「TOP30」⇒「35.7」
* 「300以上」「TOP50」⇒「33.9」
* 「100以上」「TOP10」⇒「36.2」
* 「100以上」「TOP30」⇒「30.1」
* 「100以上」「TOP50」⇒「28.1」

なお、いいね数の指数的な分布状況を考えると、本来は「平均」ではなく、
対数化した平均などを見たほうが良いと思われるのだが、
大きいか小さいかというレベルでは影響は少ないので、単純平均で話を進める。

つまり、今回作ったモデルを使うと、
いいね数の高い記事に似ている記事は、いいね数が高い
という関係が成立している。

みんながいいねをする記事には、特徴がある、ということであり、
今回作成したDoc2Vecモデルが、その特徴の一部を捉えている、ということになる。

ここまで明確な傾向が出ていると大きな希望だ。
あとは、逆に、
類似記事の平均いいね値が高いのに、自分自身のいいね値が少ない記事
を見つければいいのだ!

埋もれた良記事の発掘⇒失敗

ここまで順調にいったかに見えたが、
最後の砦が待っていて、この方法では上手くいかなかった

上記で良記事だけを対象にしていた類似検索&平均計算処理を、
全記事を対象にして実施し、
類似記事の平均いいね値が高いのに、自分自身のいいね値が少ない記事
をフィルタリングして出した。

確かに、いくつかは、いいねが少ないのに面白い記事が見つかったのだが、
面白くない記事や、書き方的にイマイチな記事もかなりヒットしてしまった。
私の主観的にはダメというだけであり、他の人が見れば面白いかもしれない、
というのは多々ありつつも、少し考察をすると・・・

「良記事」でいいね数がかなり高い記事でも、Doc2Vecモデル上では、
面白くない記事に近いと分類されてしまうこともままあるハズ。
全体的な平均を取れば、面白い側に分類されることが多いのだろうが、
個別事例が100%の精度になっているわけでは無い。
そのため逆側から見ると、そうした記事と類似している面白くない記事は、
「類似記事の平均いいね値」が高くなってしまう。
そのため、面白くない記事も結構出てきてしまうのではないか?
平均で見ればDco2Vecモデルは「いいね判断」が出来ていても、
個別事例で使おうとすると、マギレ/誤検知が多い
ということ。

この件に関しては、この記事がイマイチだった、みたいな考察は大変失礼なので、
コードと結果記載は差し控えることにする。

一応、上記のコードに加えて、
「自分自身のいいね値が平均値8以上である」
みたいな条件を入れればかなり精度は良くなる(面白い記事が見つかる)のだが、
自分自身のいいね値を参照するようなロジックでは、
「埋もれた良記事」発掘として相応しくないし、
それで発掘出来てもプログラムの力と言いにくいため、あくまでも、
「ある記事の文章だけを機械が見て、他の良記事のサンプルから類推した結果」
というロジックだけで発掘出来る方向を目指す。
(単純に良記事を見つけたいだけならば、自身のいいね値での
 フィルタを入れた方が使いやすいが、今回は、機械が見つけられるか?
 の方をテーマにしたいので、それはやらないよ、ということ)

じゃあどうするの?

正直、この時点では結構困っており、
今回の試みは失敗 or 面白くないかもしれないと思っていた。

思いついたアイデアは、
1件1件の記事を個別に確認していくのではなく、
まずざっくりと「スジの良さそうな記事」「スジの悪そうな記事」
くらいに分類することは出来ないだろうか?
というもの。
「スジの良さそうな記事」に絞れば、
それは「自分自身のいいね値が平均値8以上である」というフィルタと
同じようなフィルタとして使えるだろう、ということ。

第二仮説:良記事のざっくり分類

Doc2Vecモデル自体は、(個別に見ると誤差が多いものの)
ある程度の方向性を示すものとしては機能していると思われる。
そのため、「クラスタリング」によって、
記事を分類するということを試してみる。

クラスタリングは、「タグ」(python、rubyなど)の
自動分類などのために用いられることが多い気がする。

しかし、今回は既に、Doc2Vecのモデルにおける「類似度」を見て、
「同じタグならば類似度が近い」という状態ではなく
(※そうかもしれないが、確認はしていない)、
「いいね数が近い場合は類似度が近い」という傾向が多少はある、
ということは確認済みであるため、
このまま強引にk-meansを適用して、
いいね数が高い記事群、低い記事群、に分かれることを期待する。

クラスタリング処理の実装

今回は、k-meansで、30個のクラスタに分けてみて、
各クラスタの個数や、平均いいね数などを確認してみる。
まずは、クラスタリングそのものの処理を行う。

各記事をベクトル化して、クラスタリング from collections import defaultdict from gensim.models.keyedvectors import KeyedVectors from sklearn.cluster import KMeans import pickle key_list = range(len(model.docvecs)) print(len(key_list)) #ベクトル表現だけひたすら取り出してvectorに詰め込む。 vectors = [vector for vector in model.docvecs] print(len(vectors)) #print(vectors[5]) #クラスタ数は、先に指定する。ここがクラスタリング処理 n_clusters = 30 kmeans_model = KMeans(n_clusters=n_clusters, verbose=1, random_state=42, n_jobs=-1) kmeans_model.fit(vectors) #pickleで出来たモデルは保存しておく。 with open(r'doc2vec_30_kmeans_model_30_150.dump', mode='wb') as f: pickle.dump(kmeans_model, f) #クラスタリングのラベルを作る key_list=[] for item_info in item_info_list: iine=item_info[0] key_list.append(item_info[3]) print(len(key_list)) #print(key_list[5]) cluster_labels = kmeans_model.labels_ cluster_to_words = defaultdict(list) for cluster_id, word in zip(cluster_labels, key_list): cluster_to_words[cluster_id].append(word) #クラスタした結果を表示(各クラスタごとに3URLだけサンプル表示) for words in cluster_to_words.values(): print(words[:3])

この状態で、Doc2Vecモデルの距離が近い記事同士で、
30個に分類されたということになる。

各クラスタの情報を確認

各クラスタに対して、それぞれ下記を算出する。
* そのクラスタに含まれる記事の個数
* いいねの合計値
* いいねの平均値
* いいねの中央値
* いいねの「log2」を取った値の平均値

特に、いいねの「log2」を取った平均値が、
一定値以上のクラスタは、良記事集団の可能性がある。

今までは、単純平均で見てきたが、母数が減った集団になると、
単純平均は適切ではない。例えば、
「2」「2」「2」「2」「1024」⇒平均206
「16」「16」「16」「32」「64」⇒平均29
この場合、下の集団のほうが欲しい。(良記事集団は下の集団)
log2をとった平均で見ると、
「2」「2」「2」「2」「1024」⇒平均 2.8
「16」「16」「16」「32」「64」⇒平均 4.6
この例のように、 誤差的に入ってしまった一つの高いいね記事により、
単純平均は破壊されてしまうため、log2側で見るのだ。

各クラスタごとに、いいね平均値の確認 import statistics import math
   ITアンテナトップページへ
情報処理/ITの話題が沢山。