読者です 読者をやめる 読者になる 読者になる

StatsBeginner: 初学者の統計学習ノート

初学者が統計学、機械学習、R、Pythonの勉強の過程をメモっていくノート。

Pythonメモ: Pandasで文字列検索をループするとかなり遅かった

 前回のエントリで、Pandasのデータフレームを文字列で検索すると遅かったと書いたんですが、実際に検索の方法を変えるとどれぐらい違うのかを計測してみました。
 結論から言えば、辞書型に変換してから検索したらだいぶ速くなったのですが、それが凄いというよりは、Pandasの基本がまだ分かってないということかなと思いました。こうやると速いとか遅いとかいう記事はいろいろあるみたいなのでまた勉強したいと思います。


 一応、前回のエントリでやった方法を確認のため比較することとして、PN Tableに載っている単語をランダムに1万個選んだ上で、1個1個についてPN値を選択して取ってくるという処理の時間を測ります。
 Pandas内での工夫についてはよくしらないので、Pandasではない形で検索した場合と比べました。
 時間を図る方法は単純に、処理の直前と直後に時刻を取得して引き算するだけにしました。


 まずパターン1では、単純にPN Tableを読み込んだPandasのデータフレームで、単語の列を検索してヒットした行のPN値を取ってきます。
 次にパターン2では、単語の列とPN値の列をそれぞれリストにしておいて、先に単語のリストを検索してインデックス番号を取得し、そのインデックスでPN値のリストから値を取得します。
 最後にパターン3では、単語の列をリストにしたものと、PN値の列をリストにしたものを結合した辞書(dict型)を作っておき、この辞書をキーで検索してPN値を取得します。
 なお、PN Tableの行数は55125行です。

import pandas as pd
import random
import time

# PN Tableを読み込み(パスは適当に)
pn_df = pd.read_csv('dictionary/PN_Table/pn_ja.dic.txt',\
                    sep=':',
                    encoding='utf-8',
                    names=('Word','Reading','POS', 'PN')
                   )

# PN Tableに載っている単語を10000個抽出
word_list = list(pn_df['Word'])               # 語のリスト
searchlist = random.sample(word_list, 10000)  # 探す単語をランダムに決める


# --- パターン1:pandasで純粋に検索 --- #
start_time = time.time()          ### ▼時間計測▼ ###
pns1 = []
for w in searchlist:
    pn = pn_df.loc[pn_df.Word == w, 'PN']
    if len(pn) == 1:
        pns1.append(float(pn))
    else:
        pns1.append(0.0)
print(time.time() - start_time)   ### ▲時間計測▲ ###

# --- パターン2:リストにしてインデックスで検索 --- #
start_time = time.time()          ### ▼時間計測▼ ###
word_list = list(pn_df['Word'])               # 語のリスト
pn_list = list(pn_df['PN'])                   # PN値のリスト
pns2 = []
for w in searchlist:
    if w in word_list:
        ix = word_list.index(w)  # 先にインデックスを取得
        pn = float(pn_list[ix])  # 同じインデックスでPNリストにアクセス
    else:
        pn = 0.0
    pns2.append(pn)
print(time.time() - start_time)   ### ▲時間計測▲ ###

# --- パターン3:辞書にしてキーで検索 --- #
start_time = time.time()          ### ▼時間計測▼ ###
pn_list = list(pn_df['PN'])                   # PN値のリスト
pn_dict = dict(zip(word_list, pn_list))  # さっきのリストと合体して辞書に
pn3 =[]
for w in searchlist:
    if w in pn_dict:
        pn3.append(float(pn_dict[w]))
    else:
        pn3.append(0.0)
print(time.time() - start_time)   ### ▲時間計測▲ ###


 それぞれの時間をみてみると、

# パターン1
>>> 38.16759991645813

# パターン2
>>> 15.531062126159668

# パターン3
>>> 1.524580955505371


 となりました。何回かやってみたけどだいたい似たようなもんでした。
 辞書型に変えてから検索すると、このテストの処理で25倍速ということになりました。
 これが、昨日のエントリで行ったツイートの解析処理だと、色々ループしていることもあって、1万3000ツイートの解析を行った時間でいうと200倍速ぐらいになったわけです。


 そもそもPandasではない形にしてしまっているので無理矢理感があり、もっと勉強しないとなと思います。そもそもデータフレームに対してループで処理するのって適切なんですかね。whereなどSQLライクな関数で取ってくるべき?
 ただまぁ、とりあえず昨日やった処理のどの部分で時間がかかっているかを特定し、そこだけ方法を変えることで全体の処理が圧倒的に早まるという、目先の目的は達成されたのでよかったです。

【Python】MeCabと極性辞書を使ったツイートの感情分析入門

負のオーラを自動検出したい

 前回のエントリで、著作権侵害にあたる違法アップロード動画を自分のTwitterで拡散してしまっている懸念を考えて、YouTube動画のリンクが貼ってあるツイートをまとめて削除しました。
 前回のエントリでも言いましたが、著作権侵害モノ以外にも、「残しておくとまずいツイート」は色々ある可能性があり、たとえば誹謗中傷の類いがあるかと思います。誹謗中傷ツイートを自動抽出する方法はにわかには思いつきませんが、たぶん「クソ」とか「死ね」とか「バカ」とかそういう悪口の辞書が必要になりそうです。


 ところで、言語データの分析手法として、単語ごとに感情特性を評価した辞書というものがあちこちで作られていまして、これを使ってツイートがどのような感情を帯びているか分析するということが、よくやられています。Yahoo!がそういうツールを提供してたりもします(参考リンク)。
 Yahoo!のリアルタイム検索にキーワードを入れると(この場合は「ちきりん」)、そのキーワードに関係するツイートが流れてくると同時に、画面に右の方に、ポジティブなつぶやきが多いかネガティブなつぶやきが多いかのグラフが表示されます(この場合はネガティブが74%、ポジティブが0%、中立が26%)。


f:id:midnightseminar:20170508111557p:plain


 ひょっとしたら、感情分析の手法を用いて、自分のツイートの中から「負のオーラ」が漂っているものを自動的に抽出し、削除するというアプローチがあり得るかもしれません。
 というわけで今日は簡単な感情分析を行ってみました。


 最初に言っておきますが、今回やったような単純な手法では精度が低く、実用は厳しいです。あくまでちょっと試しにやってみました程度のアウトプットになっております。
 しかしそれでも、取っ掛かりとして手を動かして解析してみるというのは、色々知識が広がるものではあります。
 
 

さまざまな辞書

 感情分析に使う辞書ですが、私が今回使ったのは、東工大の高村教授が作って公開されている「PN Table」というやつです。
 PN Table


 この辞書の中身は、

めでたい:めでたい:形容詞:0.999645
賢い:かしこい:形容詞:0.999486
善い:いい:形容詞:0.999314
適す:てきす:動詞:0.999295
過小:かしょう:名詞:-0.942846
塗る:ぬる:動詞:-0.943453
器量負け:きりょうまけ:名詞:-0.943603
固まる:かたまる:動詞:-0.943991


 こんな感じで、単語に対応する極性情報が-1〜+1の間で割り当てられており、-1に近いほどネガティブ、+1に近いほとポジティブということになっています。後述するように、ゼロがニュートラルと言っていいのかはよく分かりません。


 日本語で似たような辞書はこれ以外にもありまして、たとえば東北大の乾・岡崎研究室のページで公開されている「日本語評価極性辞書」というものがあります。
 Open Resources/Japanese Sentiment Polarity Dictionary - 東北大学 乾・岡﨑研究室 / Communication Science Lab, Tohoku University


 他には、Yahoo!JAPAN研究所の鍜治伸裕さんという方が作られた「Polar Phrase Dictionary」というのがあり、東大のサイトにそのページがありますが、ダウンロード可能なものとして公開されてるわけではないようです。
 Polar Phrase Dictionary


 今回使ったPN Tableの作成に関する高村教授らの論文は以下の場所で読めます。
 高村大也,乾孝司,奥村学(2006).スピンモデルによる単語の感情極性抽出.情報処理学会論文誌ジャーナル,Vol.47,No.02,pp.627-637,2006.
 

 ポジとかネガとかのことを「極性」と呼んでいるわけですが、PN Tableは根性で大量の単語に極性を割り振っていったわけではなく、大部分の単語の極性情報が機械的に導出されています。
 物理学の理論を応用したモデルの詳細は、難しくて理解できねーと思いよく読んでませんが、「各電子のスピンは,上向きと下向きのうちどちらかの値をとり,隣り合ったスピンは同じ値をとりやすい.我々は,各単語を電子と見なし,単語の感情極性をスピンの向きと見なす.関連する単語ペアを連結することにより語彙ネットワークを構築し,これをスピン系と見なす.」そうです(汗)
 その利点として、「我々のモデルでは,平均場近似により語彙ネットワーク上の単語の感情極性が大域的に決定される.このような大域的な最適化を用いるからこそ,語釈文やコーパスのような,シソーラスと比べてノイズが含まれやすい(すなわち,隣り合っていても同じ極性を持たないことが起こりやすい)リソースを取り入れることが可能になるのである.最短距離を利用した手法や単純なブートストラッピングを利用した手法のような既存手法では,そのようなリソースを取り入れることはできない.」らしいです(汗)(汗)


 私にはよく分かりませんが、ともかくそういう理論物理学にヒントを得たモデルによって、まず、辞書・シソーラス・コーパスから「語彙ネットワーク」を形成しておき、そこにgood/badなどすでに判明している極性情報を注入してやることで、ネットワーク内の語彙に極性情報が伝搬されて、自動的に極性辞書ができあがるというプロセスのようです。たぶん。なんか近未来的です。
 論文の冒頭の先行研究レビューの部分をみると、単語の感情特性を求めるためにこれまでどのような手法が提案されてきたかも概観できて参考になります。
 
 

自分のツイートを評価してみる作業

 さて、PN Tableを使って自分のツイートを実際に評価してみます。
 今回は、とりあえず何となく数値っぽいものを計算するところまで行きたかったので、単純な処理だけやりました。


 前回のエントリでも使いましたが、自分のツイート全件を処理するときは、Twitterの公式サイトからダウンロードできる全ツイート履歴のCSVファイルを使うのが良いです。


f:id:midnightseminar:20170507012019p:plain


 これをPandasのデータフレームとして取り込むところから分析がスタートします。各種モジュールも最初にインポートしておきます。

# モジュールのインポート
import re
import csv
import time
import pandas as pd
import matplotlib.pyplot as plt
import MeCab
import random


# tweets.csvの読み込み
tw_df = pd.read_csv('tweets.csv', encoding='utf-8')


 このテーブルには、

  • tweet_id # ツイートごとのID(いわゆるstatus_id)
  • in_reply_to_status_id
  • in_reply_to_user_id
  • timestamp # 投稿日時
  • source # 投稿に用いたデバイス
  • text # 本文
  • retweeted_status_id
  • retweeted_status_user_id
  • retweeted_status_timestamp
  • expanded_urls # リンクが貼られている場合の、省略しない形のURL


 という10個のフィールドがありますが、今回必要とするのはtweet_idとtextだけです。
 次に、PN TableもPandasで読み込みます。先ほども例示したように、

めでたい:めでたい:形容詞:0.999645
賢い:かしこい:形容詞:0.999486
善い:いい:形容詞:0.999314
適す:てきす:動詞:0.999295
過小:かしょう:名詞:-0.942846
塗る:ぬる:動詞:-0.943453
器量負け:きりょうまけ:名詞:-0.943603
固まる:かたまる:動詞:-0.943991


 というような内容になっているので、read_csvのオプションで区切り文字を「:」と指定して読み込めばいいかと思います。*1

# PN Tableを読み込み
# パスは各自適当なものになります
pn_df = pd.read_csv('dictionary/PN_Table/pn_ja.dic.txt',\
                    sep=':',
                    encoding='utf-8',
                    names=('Word','Reading','POS', 'PN')
                   )


 まず、個々のツイートをMeCabで形態素解析して、単語に分けるとともにその基本形表記を取得します。MeCabの導入方法等は過去のエントリを参照してください。
 基本形を取得するのは、実際のツイート中に登場する未然形や連用形のように活用された形だと、極性辞書をサーチすることができないからです。


 MeCab Pythonでふつうに形態素解析をする場合、返ってくる情報は以下のような感じになります。

>>> print(m.parse('STAP細胞はあります。'))
STAP	名詞,固有名詞,組織,*,*,*,*
細胞	名詞,一般,*,*,*,*,細胞,サイボウ,サイボー
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
あり	動詞,自立,*,*,五段・ラ行,連用形,ある,アリ,アリ
ます	助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス
。	記号,句点,*,*,*,*,。,。,。
EOS


 1行1語になっていることが分かります。最後にEOSという終了記号が付いて、さらに空行が1行ついてきます。
 これを後々どうやって扱うかなのですが、とりあえず私は深く考えずに、各行をdict型のデータに格納して、リストで連結しておくことにしました。

# MeCabインスタンス作成
m = MeCab.Tagger('')  # 指定しなければIPA辞書


# -----テキストを形態素解析して辞書のリストを返す関数----- #
def get_diclist(text):
    parsed = m.parse(text)      # 形態素解析結果(改行を含む文字列として得られる)
    lines = parsed.split('\n')  # 解析結果を1行(1語)ごとに分けてリストにする
    lines = lines[0:-2]         # 後ろ2行は不要なので削除
    diclist = []
    for word in lines:
        l = re.split('\t|,',word)  # 各行はタブとカンマで区切られてるので
        d = {'Surface':l[0], 'POS1':l[1], 'POS2':l[2], 'BaseForm':l[7]}
        diclist.append(d)
    return(diclist)


 これによって、1つのツイート本文が以下のような情報に変換されます。見やすくするために改行を入れますが、

[
   {'POS': '固有名詞', 'POS1': '名詞', 'BaseForm': '*', 'Surface': 'STAP'}, 
   {'POS': '一般', 'POS1': '名詞', 'BaseForm': '細胞', 'Surface': '細胞'}, 
   {'POS': '係助詞', 'POS1': '助詞', 'BaseForm': 'は', 'Surface': 'は'}, 
   {'POS': '自立', 'POS1': '動詞', 'BaseForm': 'ある', 'Surface': 'あり'}, 
   {'POS': '*', 'POS1': '助動詞', 'BaseForm': 'ます', 'Surface': 'ます'}, 
   {'POS': '句点', 'POS1': '記号', 'BaseForm': '。', 'Surface': '。'}
]


 こういう感じのリストです。
 品詞は何かに使うかもと思って一応取得したんですが、結局今回は使いませんでした。しかし修正するのが面倒なのでこのままにしておきます。
 あとで拡張していく時にも使うかもしれません。実際、1つの文章を形態素解析して「辞書のリスト」にすることにしておけば、後で色々使いまわせるような気もします。


 次に、上で得られた単語ごとのdict型データに、PN Tableから取った極性値を項目として追加したいと思います。
 最初にPN TableをPandasデータフレームとして読み込んであったので、ふつうに考えたらこのデータフレームを検索してPN値を取ってくればいいということになります。
 たとえば、

pn_df.loc[pn_df.Word == '細胞', 'PN']


 というような処理を繰り返せばPN値は取得してこれるのですが、この方法だと死ぬほど時間がかかります。
 最初、この方法で1万3000件のツイートの解析をやってみたのですが、CPU使用率が98.8%になり、処理がなかなか終わりませんでした。シャドウバースを2試合やっても終わらなかったので、外に出て剣道の素振りをして帰ってきたらようやく終わっていました。なので、たぶん30分以上はかかったと思います。


 そこで、Pandasのデータフレームを文字列で検索するのは非常に時間がかかるので、PN Table自体を{'単語':PN値, '単語':PN値, '単語':PN値...}という形のdict型データに変換した上で、単語をキーとしてアクセスしてPN値を取ってくる方法に変更したら約8秒で終わりました。8分ではなく8秒です。30分→8秒(約200倍速)。
 また、感情辞書をこういう形にしておくことにすれば、他の辞書を使った分析へと拡張するのもやりやすいような気がしました。

# PN Tableをデータフレームからdict型に変換しておく
word_list = list(pn_df['Word'])
pn_list = list(pn_df['PN'])  # 中身の型はnumpy.float64
pn_dict = dict(zip(word_list, pn_list))


# 形態素解析結果の単語ごとdictデータにPN値を追加する関数
def add_pnvalue(diclist_old):
    diclist_new = []
    for word in diclist_old:
        base = word['BaseForm']        # 個々の辞書から基本形を取得
        if base in pn_dict:
            pn = float(pn_dict[base])  # 中身の型があれなので
        else:
            pn = 'notfound'            # その語がPN Tableになかった場合
        word['PN'] = pn
        diclist_new.append(word)
    return(diclist_new)


 PN Tableに載っていない語をどうするかなのですが、今回は分析から除くことにしました。ゼロを割り当ててしまうとPN Table上で実際にゼロちょうどと評価されている単語(「週末」「巨体」「セレナーデ」など20語ある)なのか、載ってなかった単語なのかの区別がつかないので、適当にnotfoundと書いておきました。
 これで各ツイートが以下のような形式のデータになります。

>>> test_text = 'STAP細胞はあります。'
>>> dl_test = get_diclist(test_text)
>>> dl_test = add_pnvalue(dl_test)
>>> print(dl_test)
[
    {'PN': 'notfound', 'POS': '固有名詞', 'POS1': '名詞', 'BaseForm': '*', 'Surface': 'STAP'}, 
    {'PN': -0.746254, 'POS': '一般', 'POS1': '名詞', 'BaseForm': '細胞', 'Surface': '細胞'}, 
    {'PN': 'notfound', 'POS': '係助詞', 'POS1': '助詞', 'BaseForm': 'は', 'Surface': 'は'}, 
    {'PN': 'notfound', 'POS': '自立', 'POS1': '動詞', 'BaseForm': 'ある', 'Surface': 'あり'}, 
    {'PN': 'notfound', 'POS': '*', 'POS1': '助動詞', 'BaseForm': 'ます', 'Surface': 'ます'}, 
    {'PN': 'notfound', 'POS': '句点', 'POS1': '記号', 'BaseForm': '。', 'Surface': '。'}
]


 あとはPN値の平均をとるだけ(上記の文だとPN値を持った語が1つしかありませんが)なので、正直こんなゴツい形式のデータにする必要なかったと思いますが、先ほども述べたように「何かに使うかも」と思った経緯からこんな形になっております。

# 各ツイートのPN平均値をとる関数
def get_pnmean(diclist):
    pn_list = []
    for word in diclist:
        pn = word['PN']
        if pn != 'notfound':
            pn_list.append(pn)  # notfoundだった場合は追加もしない            
    if len(pn_list) > 0:        # 「全部notfound」じゃなければ
        pnmean = mean(pn_list)
    else:
        pnmean = 0              # 全部notfoundならゼロにする
    return(pnmean)


 全部notfound、つまりPN Tableに載っている単語を1語も含まないツイートを0点と評価するのはよく考えたら適切ではなく、分析から除外すべきですが、やりなおしが面倒なのでコードはこのままにしておきますw
 ここまでできれば、あとはツイートの1件1件に対して、「形態素解析」「PN値の追加」「PNの平均値の算出」を繰り返していき、1つのリストにまとめます。

# pn値のリストを作成(一応時間を測りました)
start_time = time.time()               ### ▼時間計測▼ ###
pnmeans_list = []
for tw in tw_df['text']:
    dl_old = get_diclist(tw)
    dl_new = add_pnvalue(dl_old)
    pnmean = get_pnmean(dl_new)
    pnmeans_list.append(pnmean)
print(time.time() - start_time)        ### ▲時間計測▲ ###


 これを、ツイート全件履歴データフレームの右端に追加して、PN極性値でソートし、CSVで吐き出します。

# 一応、本文テキストから改行を除いておく(最初にやれ)
text_list = list(tw_df['text'])
for i in range(len(text_list)):
    text_list[i] = text_list[i].replace('\n', ' ')


# ツイートID、本文、PN値を格納したデータフレームを作成
aura_df = pd.DataFrame({'tweet_id':tw_df['tweet_id'],
                        'text':text_list,
                        'PN':pnmeans_list,
                       },
                       columns=['tweet_id', 'text', 'PN']
                      )


# PN値の昇順でソート
aura_df = aura_df.sort_values(by='PN', ascending=True)


# CSVを出力(ExcelでみたいならUTF8ではなくShift-JISを指定すべき)
aura_df.to_csv('aura.csv',\
                index=None,\
                encoding='utf-8',\
                quoting=csv.QUOTE_NONNUMERIC\
               )

 
 

結果をみてみる

 さてどんな結果が得られたのかみてみたいと思います。
 まずは、最もネガティブな方から十数件をみてみますと、


f:id:midnightseminar:20170507012149p:plain


 ベッキーさんの不倫事件を擁護しているツイートが最もネガティブという判定になりました。いきなり誤評価です。
 なんでこんなことになったのか確認してみます。

>>> test_text = 'ベッキーは果たしてそんなに悪いのか'
>>> dl_test = get_diclist(test_text)
>>> dl_test = add_pnvalue(dl_test)
>>> for w in dl_test:
...     print(w)
... 
{'PN': 'notfound', 'POS': '一般', 'POS1': '名詞', 'BaseForm': '*', 'Surface': 'ベッキー'}
{'PN': 'notfound', 'POS': '係助詞', 'POS1': '助詞', 'BaseForm': 'は', 'Surface': 'は'}
{'PN': 'notfound', 'POS': '一般', 'POS1': '副詞', 'BaseForm': '果たして', 'Surface': '果たして'}
{'PN': 'notfound', 'POS': '一般', 'POS1': '副詞', 'BaseForm': 'そんなに', 'Surface': 'そんなに'}
{'PN': -1.0, 'POS': '自立', 'POS1': '形容詞', 'BaseForm': '悪い', 'Surface': '悪い'}
{'PN': 'notfound', 'POS': '非自立', 'POS1': '名詞', 'BaseForm': 'の', 'Surface': 'の'}
{'PN': 'notfound', 'POS': '副助詞/並立助詞/終助詞', 'POS1': '助詞', 'BaseForm': 'か', 'Surface': 'か'}


 要するに、このツイートのなかでPN Tableに載っていた単語が「悪い」しかなく、「悪い」はPN Table上では最もネガティブな語ということになっているので、最もネガティブなツイートという判定になったわけですね。
 そもそもこの文、「か」を付けた反語になっているわけですが、このように文法構造を無視して単語だけで評価すると無理があるということが、この1例からも分かります。
 「良くない」とか「優れていないというわけでもない」みたいな表現を的確に評価しようと思ったら、「良い」「優れる」の部分だけみるのではなく、例えば係り受け解析というのを行って、これらの表現が打ち消されたりしていないかをきちんと調べないといけません。
 係り受け解析にはCaboChaというツールがありますので、これは後日また使ってみようかと思います。


 また、「オムライスなう」がなぜネガティブな評価になるのかというと、

>>> test_text = 'オムライスなう'
>>> dl_test = get_diclist(test_text)
>>> dl_test = add_pnvalue(dl_test)
>>> for w in dl_test:
...     print(w)
... 
{'PN': 'notfound', 'POS': '一般', 'POS1': '名詞', 'BaseForm': 'オムライス', 'Surface': 'オムライス'}
{'PN': -0.9999969999999999, 'POS': '自立', 'POS1': '形容詞', 'BaseForm': 'ない', 'Surface': 'なう'}


 このように、「なう」が形容詞の「ない」として判定されており、ないは否定的な言葉なので、ネガティブな評価となってしまってるわけです。上の出力には出てませんが、別途確認したら「連用ゴザイ接続」という活用形として判定されてました。
 「なう」は一つの典型例ですが、要するにTwitter独自の表現を辞書に取り込まないと、適切に評価できないことが分かります。


 つぎにポジティブなほうから十数件をみてみましょう。


f:id:midnightseminar:20170507012214p:plain


 これはネガティブ側に比べれば比較的当たってる気もしますが、「楽天ソーシャルニュースってどこが面白いんだ」のように、反語的な表現が適切に評価できていないのは先ほどみたのと同じですね。


 「ない」の扱いはややこしいので、ややこしい処理を実装すべきなんですが、試しに逆に「ない」を辞書から削除して分析してみたらネガティブランキングは以下のようになりました。

# PN Tableから「ない」を削除
rem_ix = list(pn_df[pn_df.Word == 'ない'].index)  # 2個ある
pn_df = pn_df.drop(rem_ix)


f:id:midnightseminar:20170507123034p:plain


 次に、全体として極性値の分布がどうなっているのかをみてみます。
 matplotlibでヒストグラムを描きます。

x1 = list(aura_df['PN'])
plt.hist(x1, bins=50)
plt.title('P/N Frequency of My Tweets')
plt.xlabel("P/N value")
plt.ylabel("Frequency")


f:id:midnightseminar:20170507012228p:plain


 全体的に、負の値に偏っています。ゼロのところに山ができているのは、上述のとおりPN Tableに載っている単語を1語も含まないツイートが0点と評価されてるからで、これは適切な処理ではないので無視してください。
 ところでこの結果が、ネガティブなツイートが多いことを意味しているのかというと、そうでもない可能性があります。というのも、PN Table自体のヒストグラムも取ってみると、

x2 = list(pn_df['PN'])
plt.hist(x2, bins=50)
plt.title('P/N Frequency in PN Table')
plt.xlabel("P/N value")
plt.ylabel("Frequency")


f:id:midnightseminar:20170507012238p:plain


 こんなふうになっており、そもそも大半が負の値を持つ語であるということが分かります。そういう、辞書のクセなんでしょうが、結局どのへんがニュートラルなのかはよく分かっておりません。
 
 

まとめ

 今回は、負のオーラを発する自分のツイートを発掘するために、感情分析を試みました。結果的には、単にツール(MeCabや極性辞書)の使い方を学んだだけに終わり、精度的に使いものになるような処理はできてないため、「まとめて削除」まではしていません。


 その主な原因は、わざわざ分析してみなくても誰でも分かる当たり前のことですが、

  • 文法構造を考慮に入れていない
  • Twitterの独特の表現を辞書に取り込むことができていない

 といった点になると思われます。
 しかしまぁ、そのあたりに課題があるということを、具体例をもって体験できたので、勉強にはなりました。今後は、処理を少しずつ改善して、納得のいく結果が出るかどうかをまた検証していきたいと思います。


 最後に、感情分析に関する、参考になりそうな研究(日本語のもの)を列挙しておきます。


 山本湧輝,熊本忠彦,本明代(2015).ツイートの感情の関係に基づくTwitter感情軸の決定.第7回データ工学と情報マネジメントに関するフォーラム,E5-2.
 
 鳥倉広大,小町守,松本裕治(2012).Twitterを利用した評価極性辞書の自動拡張.言語処理学会第18回年次大会発表論文集,pp.551-554.
  
 菅原久嗣(2010).感情語辞書を用いた日本語テキストからの感情抽出,修士論文(東京大学).
 
 

補記

 あとで気づいたんですが、PN Tableには基本形だけみると同一となる語(形容詞の「ない」と助動詞の「ない」など)がいくつかあるので、基本形だけ見てPN値を取ってきているところの処理には、誤りが含まれる可能性があります。取り急ぎ修正はしてないです。以下は例です。
 こういうのを考えると、上で使わなかった、形態素解析結果の品詞情報が、マッチング精度を上げるのに使えますし、さらに項目を追加して読み方の情報も取っておくべきですね。

22 助ける たすける 動詞 0.998356
487 助ける すける 動詞 0.990702


55117 ない ない 形容詞 -0.999882
55120 ない ない 助動詞 -0.999997


37424 頭 がしら 名詞 -0.466818
40768 頭 あたま 名詞 -0.513451
42098 頭 ず 名詞 -0.534602
42411 頭 つむり 名詞 -0.539412
43175 頭 かぶり 名詞 -0.551798
45300 頭 つぶり 名詞 -0.591628
50473 頭 かしら 名詞 -0.777183
52463 頭 とう 名詞 -0.980576


1781 人気 にんき 名詞 0.967650
3272 人気 じんき 名詞 0.213135
3851 人気 ひとけ 名詞 0.114632
10822 人気 ひとげ 名詞 -0.141334


2303 縁 えん 名詞 0.887527
38778 縁 ふち 名詞 -0.485352
41377 縁 へり 名詞 -0.523025
43027 縁 えにし 名詞 -0.549426
43872 縁 ゆかり 名詞 -0.564371
50448 縁 よすが 名詞 -0.775915


37424 頭 がしら 名詞 -0.466818
40768 頭 あたま 名詞 -0.513451
42098 頭 ず 名詞 -0.534602
42411 頭 つむり 名詞 -0.539412
43175 頭 かぶり 名詞 -0.551798
45300 頭 つぶり 名詞 -0.591628
50473 頭 かしら 名詞 -0.777183
52463 頭 とう 名詞 -0.980576


 また、その件を掘っていて、PN Table内に不可解な情報をみつけました。

15907 ホーム ホームラン 名詞 -0.199562
19438 ホーム ホームスパン 名詞 -0.238954
21561 ホーム ホーム 名詞 -0.263255
21588 ホーム ホームドクター 名詞 -0.263565
21936 ホーム ホームステイ 名詞 -0.267942
23736 ホーム ホームドラマ 名詞 -0.289906
23854 ホーム ホームシック 名詞 -0.291620
26676 ホーム ホームグラウンド 名詞 -0.327826
28151 ホーム ホームルーム 名詞 -0.347714
28695 ホーム ホームストレッチ 名詞 -0.354835
32726 ホーム ホームヘルパー 名詞 -0.408128


11151 太刀 たちうち 名詞 -0.145596
21081 太刀 たちさばき 名詞 -0.257552
21521 太刀 たちうお 名詞 -0.262799
21820 太刀 たちすじ 名詞 -0.266431
22494 太刀 たちかぜ 名詞 -0.274693
28816 太刀 たちとり 名詞 -0.356338
33218 太刀 たち 名詞 -0.414187
36886 太刀 たちさき 名詞 -0.459479
48426 太刀 たちもち 名詞 -0.673302


1063 大人 たいじん 名詞 0.982811
2714 大人 だいにん 名詞 0.397852
3153 大人 おとなしい 形容詞 0.243448
3471 大人 おとな 名詞 0.178366
3786 大人 うし 名詞 0.124680
30217 大人 おとなびる 動詞 -0.375421
52038 大人 おとなげない 形容詞 -0.960539


2277 トップ トップ 名詞 0.911679
32105 トップ トップダウン 名詞 -0.400048
32413 トップ トップニュース 名詞 -0.404087
34165 トップ トップコート 名詞 -0.426013
37862 トップ トップマネージメント 名詞 -0.472922


1509 キング キング 名詞 0.974291
7688 キング キングメーカー 名詞 -0.092334
15140 キング キングサイズ 名詞 -0.191609


 自動生成された辞書なので、こういう誤りみたいなものも含まれてるんでしょうね。
 また、「人気」とか「大人」に関して実際にはほとんど使わない読み方が登録されているあたりに、このテーブルが、コーパスよりは日本語辞書を土台にして作られていることが伺えます。

*1:読み仮名のことを「reading」としているサイトがあったのでそうしましたが自信ないです。pronounciationとかphoneticとかが正解なのかな?POSというのは品詞(part of speech)のことです。PNはポジネガの値という意味で付けました。

エンジニア泣かせの「日本の住所」は誰が決めているのか

 住所のデータを機械で扱おうと思った時、日本の住所は「1の1」と「1丁目1番」と「1-1」のように表記が統一されていないことや、アメリカ等のようにカンマでの分かち書きがされていないなどの理由によって、処理が難しいというのはよく知られた話です(分かち書き問題についてのわかりやすい記事はこちら)。
 それで苛立ったエンジニアの人が「なんで統一ルールを作らねぇんだよ!」とブチ切れたりすることがあったりするわけですが(私はエンジニアではないのでブチ切れません)、「じゃあその統一ルールは誰が作ればいいんだ?」と考えると、「そもそも誰が決めてるんだっけ?」という疑問に行き当たります。
 そこで、日本の住所が何に基づいてどのように決定されているのかについて、備忘のために要約しておきました*1。未確認事項が2点残っており、分かり次第追記します。というか知ってる方いたら教えてください。


 法的根拠のある権限に基づいて、誰かが定義したり決定したりしている住所というのは、以下の1〜5ですべて説明できるはずです(6は法的根拠なしです)。

  1. 市区町村:当該地域の議会の議決を経て、都道府県知事が国に届け出る。
  2. 町又は字:地方自治法260条に従って、市区町村が議決の上都道府県知事に届け出、知事が告示する。
  3. 街区:町又は字の下の単位であり、「番地」とかが該当。住居表示に関する法律に従って市区町村が決める。
  4. 住居番号:街区に含まれる住居に番号を振るもの。「○番●号」の「●号」のこと。住居表示に関する法律に従って市区町村が決める。
  5. 地番:不動産登記法に基づいて登記所(法務省の出先機関)が決める、土地に振られた番号。住居表示とは別物なので注意。
  6. 部屋番号等:住居番号に集合住宅の番号まで含めている場合以外は、法律上の根拠はない。


 「2.町又は字」には、「大字」「小字」「町」「丁目」などが全部含まれます。「丁目(丁)」は、地方自治法の概念上は「町又は字」に含まれるので、たとえば「霞が関1丁目」で一つの町・字を構成しています。郵便番号は丁目の手前までを表すので、混乱しやすいです。
 またこれは、「市区町村」というときの「町」とは別モノです(間違える人はいないでしょうが)。私は昔、「大阪府豊中市新千里北町」という「町」に住んでましたが、こういう「町」のことです。


 京都市内の「上ル」とか「西入ル」とかは有名で(わかりやすい解説はこちら)、単位としては「町又は字」の上位(前に付ける)にあたるのですが、これは慣習的な呼称であって法的な位置づけはありません。つまり、誰かが法令に則って決定したり定義したりしているわけではない。
 ただしこれらは住基の情報として登録されているとのことなので、住民基本台帳法に則った市区町村長の権限による住民登録事務として、お墨付きが与えられているとは言えるかもしれません。
 なお、「京都市上京区今出川通浄福寺西入二丁目東上善寺町」のように、丁目が町の前に付いている場合がありますが、これが「町又は字」の一部なのか、慣習的な表記の一部なのか確認していません。そのうち分かったら追記します。


 「3.街区」には、「街区方式」と「道路方式」があり、「街区方式」の場合はたいてい数字になっていて、いわゆる「番地」(「○番●号」の「○番」)がこれに相当します。この「○番」の数字を街区符号と呼びます。街区符号は数字が一般的ですが、アルファベットや漢字の場合もあります。「道路方式」の場合は、住所は「○○通り●号」とかになります。


 「3.街区」と「4.住居番号」をあわせて、「住居表示」と言います。住居表示に関する法律は市街地を対象としたものなので、田舎では住居表示が整備されていません。その場合は、町・字より下の単位として「5.地番」が住所として用いられることになります。つまり「1 + 2 + 5」という住所になっているということです。


 集合住宅の部屋番号は、「4.住居番号」として定義される(つまり住居表示の一部になっている)場合と、「6.部屋番号等」に相当するような、住居表示に含まない「方書」として定義されている場合の両方があります。ちなみに方書は戸籍の登録時には含まないことになっています。


 「5.地番」は登記所、つまり国の出先機関が決めているので扱いがややこしく、住居表示によっても表現できるし地番によっても表現できるというような家があり得るわけですが、戸籍法では、戸籍を登録するときに住居表示を用いても地番を用いても良いことになっています。
 「地番は、不動産登記法では「市、区、郡、町、村及び字」の下に付ける番号および枝番のことになってるんですが、この法律でいう「字」に、「2.町又は字」の「町」が含まれるのか分かりませんでしたので、わかったら追記します。
 「番外地」とはこの「地番」が振られていない土地(例えば、明治以来ずっと国有地である場所は登記されたことがないため地番がない)のことです。


 こうやって整理してみると、住所表記の統一ルールを作ると言った場合、そもそも「住居表示」が全ての建物をカバーしていないという点がネックになりそうですね。決定権限の異なる「地番」とまざった形で戸籍が作られているというのはややこしいです。
 京都市の例のような慣習的な呼称については、何通りもある可能性がありますが、どれか1個を選んで「町又は字」に取り込んで自治体が決定してしまえばいいような気もします。もともと法的根拠がないのだから、新たに定義することによって「歴史的な呼称が消えてしまう」というわけでもないでしょう。

*1:Wikipediaと法律と役所のホームページを読んだだけです

Python作業メモ: 残しておくとマズそうな自分のツイートをまとめて削除する(YouTubeリンク編)

著作権侵害にあたるYouTube動画へのリンクを削除する

 たまにツイッターで、YouTubeのリンクを貼り付けたツイートをしていますが、よく考えたら違法アップロードに該当するものを拡散してしまっている可能性があります。可能性とかいう曖昧なレベルで考えるのが面倒なので、いっそのことYouTubeのリンクを共有した過去のツイートをいっぺんに削除してしまおうと思いました。


 ついでに、人の悪口を言ってるようなツイートも削除して、自分のアカウントを「とてもいい人のアカウント」に変貌させていこうかとも思い出しましたが、まだ手法が検討できていません。(私の場合は必要性が低いですが、最近の就活生などには必要かも?)
 ツイートの感情分析を行って、負の感情の高ぶりが感じられるツイートを自動削除していけばいいんでしょうかね。本文を形態素解析で単語に分けて、人名を含むツイートを抽出した上で、さらに単語ごとに感情のポジ/ネガ度合いを定義した辞書*1*2を使って評価するようなことはできると思いますが、誹謗中傷・罵詈雑言のようなものを特定することってできるんでしょうかね。よくわからないのでそれは後回しにし、今日はYouTubeのリンクが入ってるツイートを削除するという、一瞬で終わる作業だけやりました。


 ところで、TwitterのAPIから自分のツイートを全件調べようとしても無理です。APIでツイートをまとめて取得する系の機能では、過去3000件分しか情報が取れないからです(ツイートidを個別に指定して、もっと古いツイートを参照したり操作したりすることはできる)。なので、Twitterが公式に提供している「全ツイート履歴ダウンロード」の機能を使えばいいと思います。まず全件履歴から該当するツイートのidを取得して、その後にAPIからid指定で消すわけです。


f:id:midnightseminar:20170504122227p:plain


 ダウンロードしたzipファイルを解凍するとフォルダになってるのですが、その中の'data/js/weets/'という場所に'2017_05.js'というようなファイル名で、月ごとのツイートがJavaScriptで保存されています。中身をみると、

Grailbird.data.tweets_2017_05 =
[{
  1件目のツイートデータ
}, {
  2件目のツイートデータ
}, {
  3件目のツイートデータ
}]


 というような感じで、配列の中にツイートのjsonが埋め込まれたような内容になっています。
 最初はこれを使おうと思ったのですが、よく見たら解凍後のフォルダの直下に'tweets.csv'というファイルも入っていて、上記のjsファイルよりも情報量は少ないのですが、ツイートのID、本文、リンク、retweetとreply関係の情報が含まれており、今回の目的には十分だったので、そっちを使うことにしました。

該当ツイートの抽出方法

 私の場合、YouTubeのリンクを含むツイートは3通りの経路で投稿しています。

  1. MacまたはiPhoneのSafariから、[↑]マークの共有ボタンでツイートする。
  2. YouTube側のツイッター連携機能でツイートする。
  3. YouTubeの動画ページをはてブに登録する際に、はてブの機能でブクマをツイートする。


 1つ目の方法だと、ツイート内に"https://www.youtube.com/watch?v=なんたらかんたら"とか"https://m.youtube.com/watch?v=なんたらかんたら"というURLが含まれます。(本文の見た目上はhttps://wwwなどは省略されますが、リンク先情報として入っている。)
f:id:midnightseminar:20170504122305p:plain


 2つ目の方法だと、"https://youtu.be/なんたらかんたら"という短縮URLが含まれます。
f:id:midnightseminar:20170504122411p:plain


 3つ目の方法だと、"http://htn.to/なんたらかんたら"という短縮URLが含まれますが、これははてブが生成するものなので、YouTube動画以外のリンクも同じ形式になります。
f:id:midnightseminar:20170504122421p:plain


 1つ目の場合は、リンクのところに"youtube.com"が含まれるツイートを検索すればOKです。
 2つ目の場合は、同じく"youtu.be"が含まれるツイートを見つければいいです。
 3つ目の場合は、htn形式のURLを探すと関係ないものも含まれてしまいます。ツイート本文に"動画タイトル - YouTube"という文字列が含まれる場合がほとんどなので、この文字列を探すことにします。
 例外があるかもしれませんが、これでほぼカバーできるでしょう。
 いずれも本文のデータを参照すれば見つかる気がしますが、今回はURLについてはexpanded_urlsというフィールドを見ることにしました。

Pythonでの作業

 以下、作業内容です。

import pandas as pd
import csv
import tweepy


# csvをpandasに読み込み
tw_df = pd.read_csv('解凍後フォルダ/tweets.csv', encoding='utf-8')


# 条件に合致する行を抽出し、ツイートID、本文、リンク先URLのフィールドだけ取得。
yt_df = tw_df.ix[\
      (tw_df['text'].str.contains(' - YouTube'))\
    | (tw_df['expanded_urls'].str.contains('youtube\.com'))\
    | (tw_df['expanded_urls'].str.contains('youtu\.be'))\
    , ['tweet_id', 'text', 'expanded_urls']]


# あとで念のため目視確認用に、抽出したツイートのcsvを出力しようと思っており、
# 改行コードが含まれていると何かをミスりそうなので半角スペースに置き換える。
# pandasの要素を直接書き換える方法がよくわからなかったので、textのフィールド
# をリストとして取り出して、そのリストを使ったデータフレームを新たに作った・・・
text_list = list(yt_df['text'])
for i in range(len(text_list)):
    text_list[i] = text_list[i].replace('\n', ' ')
yt_df = pd.DataFrame({'tweet_id':yt_df['tweet_id'],
                      'text':text_list,
                      'expanded_urls':yt_df['expanded_urls'],
                     },
                     columns=['tweet_id', 'text', 'expanded_urls']
                    )


# 確認用にcsvを出力
yt_df.to_csv('~/Desktop/yt.csv', index=None, encoding='utf-8', quoting=csv.QUOTE_NONNUMERIC)


# 削除すべきツイートのidリスト
rem_ids = list(yt_df['tweet_id'])


# 削除の処理はAPIで自分のアカウントを操作して行います。
# 件数があまりにも多い場合、リクエスト制限にひっかかります。


# APIハンドラインスタンスの生成
CONSUMER_KEY = '自分のコンシューマキー'
CONSUMER_SECRET = '自分のコンシューマシークレット'
auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)
ACCESS_TOKEN = '自分のアクセストークン'
ACCESS_SECRET = '自分のアクセスシークレット'
auth.set_access_token(ACCESS_TOKEN, ACCESS_SECRET)
api = tweepy.API(auth)


# 削除します。
# 試しにやってみてる時に何件か削除してしまい、どれを削除したか
# わからなくなったので、try-exceptで飛ばすことにしました・・・
for id in rem_ids:
    try:
        api.destroy_status(id)
    except:
        print('couln\'t delete id:' + str(id))
        continue


 pandasのto_csvでcsvを出力する時、引用符を付けるかどうかは、quotingという引数にPythonの標準モジュールcsvのプロパティを与えれば良いです。数値以外の情報に引用符をつけるなら'csv.QUOTE_NONNUMERIC'、全部に引用符を付けるなら'csv.QUOTE_ALL'、付けないなら'csv.QUOTE_NONE'といった具合。


 べつに著作権の侵害に当たらないような動画リンクも多いわけで、私の場合上記の条件で抽出した結果は220件程度(ツイート総数は1万3000件ぐらいです)だったので、目視確認用に出力したCSVをExcelなどで表示して、手作業で安全なやつを削除対象から除くことはできたと思います。しかし過去のツイートに特に未練はないので全部削除としました。

黒歴史の抹消について

 動画のリンクに限らず、残しておくと都合の悪いツイートを一気に消すという作業は、一部の就活生などには需要があるかもしれません。

  • 著作権侵害
  • 誹謗中傷、差別的言動
  • 政治的発言
  • 極度の下ネタ
  • 機密情報
  • 違法行為の告白(未成年の飲酒など)
  • 他人のプライバシー情報(顔写真なども)
  • 自分のプライバシー情報


 など、つい投稿してしまったけど消したいつぶやきはけっこうあるかもしれません。これらは件数が少なければ、根性で洗っていけば手作業で消すことはできるでしょう。また考え方として、古いツイートを残しておいてもとくにメリットはない場合が多いと思うので、一定期間経過したツイートはすべて消すという考え方もありなような気がします。


 しかしTwitterのデータは色々なサイトが勝手に収集して保存しているので、全部に削除依頼を出すのは大変です。というか無理。
 ネット上では、黒歴史的なものを完全に消し去ることはできないのだと覚悟を決めて、匿名管理を徹底するか、上品なつぶやきを心がけるかしたほうがいいですね。

フォルダの差分同期をExcel(VBA)で行う

 VBAの勉強エントリです。
 自分がデータ分析とかをやる場面ではべつにVBAを使う必要はなく、RやPythonでやればいいのですが、会社の仕事で他の人たちと共同作業する上では、Excelとかのマクロが組めると便利だろうなと思うことが多いです。
 しかしほっといたらいつまでたっても勉強しないので、「とりあえず使いこなせるレベルにはならなくていいから、VBAで何かやるときの作業イメージをつかんで、いざ必要になったときの心理的ハードルを下げておきたい」と思い、ちょっといじってみています。
 何かVBAでできそうなタスクがあったときに、「ググりながら時間かければ俺でも何とかできるかも」という前向きなモチベーションを持てるようにしておくのが目的です。
 
 

フォルダの同期

 VBAで2つのフォルダの中身を比較して、差分の同期をする方法を検索したら、robocopyというWindowsのコマンドをVBAから起動する方法が載っていた。
【robocopyコマンドでフォルダーをバックアップ/同期する】【エクセル2013,VBA】 - DuKiccoの雑記
Office TANAKA - Excel VBA Tips[MS-DOSコマンドの標準出力を取得する]
Tech TIPS:Windowsのrobocopyコマンドでフォルダーをバックアップ/同期させる - @IT


 VBAの練習台にと思って、適当にボタンを配置して、同期元と同期先のフォルダをそれぞれ選択し、同期するマクロを作りました。
 練習台なのでかなり適当です。
 「同期元を選択」ボタンを押すとダイアログが開くので、フォルダを選択すると、E4セルに書きこまれます。「同期先を選択」はE9セルに書き込みます。
 それで「同期!」ボタンを押すと差分同期が始まり、B15以下のセルに処理のログが書きこまれます。「結果の削除」ボタンを押すとログが消えます。


f:id:midnightseminar:20170317220352p:plain
 
 

同期元フォルダを選択するボタン用のコード

 前回のエントリでも使った「msoFileDialogFolderPicker」ってのを使い、GUIで対話的にフォルダを選択して、選択したフォルダのパスを所定のセルに記入するようにします。

Sub GetSrcPath()

    Dim strSrcPath As String                           ' フォルダのパスを格納する変数
    Dim dlgFolder As Office.FileDialog                 ' ダイアログを受け取る変数
    Set dlgFolder = Application.FileDialog(msoFileDialogFolderPicker)
    
    If dlgFolder.Show = False Then                     ' キャンセルが押されたら抜ける
        Exit Sub
    
    Else
        strSrcPath = dlgFolder.SelectedItems(1) & "\"  ' 選択されたフォルダのパスを受け取る
        Range("E4").Value = strSrcPath                 ' セルに書き込む
    End If
    
End Sub

 
 

同期先フォルダを選択するボタン用のコード

上とほぼ同じです。

Sub GetDstPath()

    Dim strDstPath As String                           ' フォルダのパスを格納する変数
    Dim dlgFolder As Office.FileDialog                 ' ダイアログを受け取る変数
    Set dlgFolder = Application.FileDialog(msoFileDialogFolderPicker)
    
    If dlgFolder.Show = False Then                     ' キャンセルが押されたら抜ける
        Exit Sub
    Else
        strDstPath = dlgFolder.SelectedItems(1) & "\"  ' 選択されたフォルダのパスを受け取る
        Range("E9").Value = strDstPath                 ' セルに書き込む
    End If
    
End Sub

 
 

フォルダを同期するボタン用のコード。

 前提として、VBEの参照設定で「Windows Script Host Object Library」への参照をONにしておく必要があります。
 WScript.Shellは、Execメソッドに対して文字列でWindowsのコマンドを与えてやればそれが実行されるようで、かなり便利ですね。
 なおrobocopyは、MacやLinuxのrsyncっていうコマンドに似ていて、「robocopy src dst /option」という書き方で、srcからdstへのコピーを行ってくれるようです。/mirというオプションは、フォルダの中がばっちり同じ内容になるやつ。
 コマンドの実行に時間がかかるので、VBA側では、処理が終了するまで待つためのループを書くらしいのですが、ふつうに書くとOSのコマンドに処理を投げたあとはほったらかしでVBA内の次の処理に行ってしまうということなんでしょうか。
 処理のログは、1つのセルに書くと見づらいので、改行コードで分割して配列にし、ループで1行1行書いていくようにしました。

Sub RunRoboCopy()

    Dim WSH    As IWshRuntimeLibrary.WshShell
    Dim wExec
    Dim sCmd   As String
    Dim Result As String
    Dim Src    As String
    Dim Dst    As String
    Dim Lines  As Variant
    Dim i      As Integer

    Src = Range("E4").Value '同期元フォルダのパス
    Dst = Range("E9").Value '同期先フォルダのパス

    Set WSH = CreateObject("WScript.Shell")

    sCmd = "robocopy " & Src & " " & Dst & " /mir"  '/mirオプションを付けたrobocopyコマンド
    Set wExec = WSH.Exec("%ComSpec% /c " & sCmd)    'コマンドの実行

    Do While wExec.Status = 0                       '処理が終了するのを待つループ
        DoEvents
    Loop

    Result = wExec.StdOut.ReadAll     ' 結果の取得
    Lines = Split(Result, vbCrLf)     ' 改行コードで分割して配列を得る
    For i = 0 To UBound(Lines)        ' 1行ごとに出力するループ
        Cells(i + 15, 2) = Lines(i)
    Next i

    Set wExec = Nothing
    Set WSH = Nothing

End Sub

 
 

結果を削除するボタン用のコード

下端まで指定する方法が良く分からなかったので1万行目まで消すようにした。

Sub ClearResult()
    Range(Cells(15, 2), Cells(10000, 2)).Clear
End Sub

メールデータ解析のため、Outlookの分類フォルダをまたいでメッセージを一括テキスト変換

 Outlookのマクロ(VBA)に関するエントリです。


 オライリーの『入門機械学習』はRによる機械学習の教科書で、正直どっちかというと今は「同じタイトルでPythonによる分析の教科書」の方が欲しい感じなのですが、これもけっこう写経しているだけでも勉強になります。


入門 機械学習

入門 機械学習

  • 作者: Drew Conway,John Myles White,萩原正人,奥野陽,水野貴明,木下哲也
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2012/12/22
  • メディア: 大型本
  • 購入: 2人 クリック: 41回
  • この商品を含むブログ (11件) を見る


 代表的な機械学習モデルの基本的な処理が一通り解説されており、実際にサンプルデータを使って一からコードを書いて分析を進めていくという内容で、前処理から順番に「こういうところでこういう問題が起きるだろ?だからこうやって解決するんだぜ」みたいな感じになっており、プロジェクトを進行させていくノリで書かれていて面白いです。
 それで、この教科書の最初の演習は、ナイーブベイズ分類器によるスパムメールの検出になってるのですが、これを読むとなんか「自分のメールを練習台にして色々分析してみたい」という気になってきます。


 ところで、メールボックスに溜まっているメールのデータを解析しようと思ったら、とりあえずテキストファイルでメール1通1ファイルみたいなデータが欲しいところです(区切りがハッキリしていれば1つのテキストファイルにつなげて書いてあっても良いですが)。
 『入門機械学習』でも、1通1ファイルのテキストファイルを使って分析を進めていました。


 それで、Windowsのパソコンで使っているOutlook2010内のメールデータを、どうやって吐き出そうかと考えました。Gmailのデータでもやってみたいですが、それは別途考えることに。
 一応、Outlookでメールボックスを開き、メッセージを全選択して「ファイル」→「名前を付けて保存」でTXT形式を選択すると、メッセージ全件が1つのテキストファイルにまとまったものを出力することができます。
 しかしこの方式だと、出力されたテキストファイル中でメールの区切りが厳密に判定するのが無理そうでした。また、私の場合メールボックスのフォルダ分類を細かくやりすぎて大変なことになってるので、全部やるのがめんどくさい。
 そこで、メッセージを「1通1ファイル」にして吐き出すマクロを書くことにしました。といっても私はVBAをほとんど触ったことが無いので*1、ググって色々参考にしながら作業しました。
 以下、ディスク上の保存先フォルダとの混同を避けるため、Outlookアプリ上に表示されるメールボックスの「フォルダ」は「ボックス」と呼ぶことにします*2


 1つのメールボックスの中身を、1通1ファイルで出力するだけなら、↓の知恵袋で紹介されているコードが使えます。
Outlookの複数のメールを、ワードファイルまたはテキストファイルとして... - Yahoo!知恵袋


 しかし私はかなりたくさんのボックスに、しかも階層的にメッセージを保存しているので、その全体を再帰的に掘れるようなマクロじゃないと使えません。そこでさらにググって見つけた、


選択したフォルダーとそのサブフォルダーのすべてのアイテムを MSG ファイルとして保存するマクロ | Outlook 研究所


 このページで紹介されているマクロを使わせて頂くことにしました。写経してたら少しだけVBAの雰囲気を感じ取ることができました。
 これはmsgファイルとして保存するコードだったんですが、これを少し改変して使いました。


 上記サイトのコードだと、保存フォルダを1つ決めてパスをコード中に書き込んでおき、Outlook上でボックスを選択してこのマクロを実行すれば、選択中(アクティブ)のボックスに属しているフォルダやメールが、その階層構造を保ったまま、保存フォルダに記録されます。
 メール1件を「日付_時刻_件名」という形式のファイルにする処理や、ボックスの階層を再帰的にたどっていく処理を書いてくれているので、大変助かります。 
 
 
 ただ私の場合、10年分ぐらいのメールが、いくつものOuotlookメール保存データファイル(pstファイル)に分けて記録されていて、それぞれを開くとまたいくつものボックスに分かれています。上記のコードでやる場合、pstファイルの単位で保存先のフォルダを定義し、いちいちコード中に書き込んでから実行しなければならない。
 それだとちょっと手間なので、

  1. Outlookでpstファイルを開いた状態でpstファイル(を表すメールボックス名)を選択する
  2. マクロを実行する
  3. 選択したメールボックス名と同名のフォルダを、保存フォルダ内に生成する
  4. 生成したフォルダの中に、メールのデータを、メールボックスの階層構造に従って記録していく


 といったことがやりたい。
 そこで結論としては、上記サイトのマクロに、

  • "olMSG"(msgファイル形式を表す)をolTXT(txtファイル形式を表す)に変更
  • ファイル命名時の拡張子を".msg"から".txt"に変更
  • 選択中のメールボックスの名前を取得して、その名前のフォルダを生成し、それを保存フォルダとする


 という変更を加えました。
 あと、元サイトのコードでは「Dim objItem As MailItem」の部分のAs以下がコメントアウトされてましたが、たぶん間違いなので「'」を取りました。コメントアウトしてあっても、方がvariant型になるだけで、マクロ自体は正常に動きますが。
 コードは以下のとおりです。コメントは私が細かく付しましたが、なんか見づらくなりました。


【追記】
 保存先のフォルダを、GUIで対話的に選択できるように、コードを追加しました。
 Outlook自身はFileDialogメソッドをサポートしていないので、裏でExcelを起動してダイアログを表示させ、選択したフォルダのパスを取得してExcelを終了するというルーチンを追加することになります。
 前提として、VBEの「ツール→参照設定」で、「Microsoft Excel 14.0 Object Library」を参照可能にしておかないと、上手く動きません。
 コードは以下のページを参考にしました。
マクロ実行中にフォルダーを選択・指定してもらう-FileDialog(msoFileDialogFolderPicker):エクセルマクロ・Excel VBAの使い方
Outlook: FileDialog object error - Microsoft Community


 メインのルーチンから、「ダイアログで保存先フォルダを選択させる」ルーチンと、「選択中ボックス内のアイテムを再帰的に掘ってテキストファイルとして保存先フォルダに保存していく」ルーチンを、順番に呼び出していますが、前者において、ユーザがキャンセルボタンを押して選択を中止した場合の処理を書く必要があります。このとき、選択用ルーチン(Private Function)内でExit Functionするだけだと、呼び出し元は停止しないで、後者の保存用ルーチンが実行されてしまい、実際やってみたらDドライブ直下に保存されるという動きになりました。
 なので、キャンセルボタンが押された場合は、呼び出し元のメインルーチンに対して停止信号となるような返り値を返してやり、メインルーチン自体をExitするようにしました。
【/追記】
 
 

' 以下は、メインのルーチン。
' ディスク上の保存フォルダを指定し、Outlookで選択中のメールボックス名を
' 取得して同名のフォルダを保存フォルダ内に生成した上で、選択中のメールボックス
' と保存先を引数として保存用ルーチンに処理を投げる。

Sub SaveMailsAsText()
	Dim Selected As String
	Selected = SelectFolder()      ' フォルダを選択させるルーチンを呼び出して返り値を取得

	If Selected = "::::STOP::::" Then  ' 停止信号を受け取ったら、このメインルーチンを停止
		Exit Sub

	Else
		Dim SAVE_PATH As String
		SAVE_PATH = Selected & "\"                                         ' パスの最後に\を付ける必要がある
		Dim objFSO As Object                                               ' ファイルシステムオブジェクトを入れる変数の宣言
		Set objFSO = CreateObject("Scripting.FileSystemObject")            ' ファイルシステムオブジェクトの生成
		objFSO.CreateFolder SAVE_PATH & ActiveExplorer.CurrentFolder.Name  ' 選択中のメールボックスと同名のフォルダを生成
		
		' 保存用ルーチンに処理を投げる
		SaveFolderRecursive ActiveExplorer.CurrentFolder, SAVE_PATH & ActiveExplorer.CurrentFolder.Name & "\"
		
	End If
	
End Sub


' 以下は、ダイアログを表示して対話的にフォルダを選択させ、パスを取得するルーチン。
' Outlook自体はFileDialogメソッドをサポートしていないので、裏でExcelを起動して、
' ExcelのFileDialogメソッドでパスを取得し、変数に格納した上で、ExcelをQuitする。
' 前提として、VBEのツール→参照設定でExcelオブジェクトライブラリを参照可にしておく必要がある。

Private Function SelectFolder() As String

	Dim strFolderPath As String               ' フォルダのパスを格納する変数
	Dim objSurrogate As Object                ' 裏で動くエクセルを受け取る変数
	Dim dlgFolder As Office.FileDialog        ' ダイアログを受け取る変数

	Set objSurrogate = New Excel.Application  ' Excelを起動
	objSurrogate.Visible = False              ' ユーザには見せない

	' エクセルから、フォルダを選択させるダイアログを起動
	Set dlgFolder = objSurrogate.Application.FileDialog(msoFileDialogFolderPicker)
    
	' キャンセルボタンがクリックされたら呼び出し元ルーチンに停止信号を送る
	If dlgFolder.Show = False Then

		SelectFolder = "::::STOP::::"  ' 適当に考えた停止信号

	Else

		' 選択されたフォルダーのパスを変数に格納
		strFolderPath = dlgFolder.SelectedItems(1)

		objSurrogate.Quit                      ' Excelを終了
		Set objSurrogate = Nothing             ' 参照の解除(不要?)
		MsgBox strFolderPath & "に保存します"  ' 確認メッセージの表示(OKを押させるだけ)

		SelectFolder = strFolderPath           ' Functionプロシージャは関数名に返り値を格納する

	End If

End Function


' 以下は保存用ルーチン。
' Outlook上での選択中メールボックスを受け取って、当該ボックス内のアイテムに
' ファイル名を付けて保存フォルダに保存していく。
' 最後の方に、「ボックス内のボックス(サブボックス)」に対してこのルーチン
' 自身を適用する入れ子構造が埋め込まれているので、ボックスの階層を再帰的に
' 辿っていくことができる。

Private Sub SaveFolderRecursive(objFolder As Folder, strSavePath As String)
	On Error Resume Next       ' エラーを無視
	Dim objItem As MailItem    ' メールアイテム用の変数を宣言
	Dim strFileName As String  ' 保存する際のファイル名用の変数を宣言
	Dim i As Integer           ' ループのカウンター用の変数を宣言
	Dim arrErrChars            ' ファイル名・パスに使えない文字一覧用の変数を宣言
	Dim objFSO                 ' ファイルシステム操作用の変数を宣言
	Dim objSubFolder As Folder ' Outlookのサブボックス取得用の変数を宣言
	arrErrChars = Array("\", "/", ":", "*", "?", """", "<", ">", "|")  ' ファイル名・パスに使えない文字
	Set objFSO = CreateObject("Scripting.FileSystemObject")            ' ファイルシステムオブジェクトの生成

	' 選択中ボックスを受け取って中味のアイテム一覧からforループ
	For Each objItem In objFolder.Items
		' 日時と件名からファイル名生成
		strFileName = Format(objItem.ReceivedTime, "yyyymmdd_hhnn_") & objItem.Subject

		' エラーが発生したら受信日時ではなく最終更新日時をファイル名とする
		If Err.Number <> 0 Then
			strFileName = Format(objItem.LastModificationTime, "yyyymmdd_hhnn_") & objItem.Subject
			Err.Clear
		End If

		' ファイル名に使えない文字を_に置き換えるループ
		For i = 0 To UBound(arrErrChars)
			strFileName = Replace(strFileName, arrErrChars(i), "_")
		Next

		' ファイル名が 260 文字を超えないように、左から250字を取る
		strFileName = Left(strSavePath & strFileName, 250)

		' 同一日時に同じ件名のメールがあるとファイル名が同じになるので、
		' 同名のファイルがすでにある場合は2~の連番を付けるというループ
		If objFSO.FileExists(strFileName & ".txt") Then
			i = 2
			While objFSO.FileExists(strFileName & "(" & i & ").txt")
				i = i + 1
			Wend
			strFileName = strFileName & "(" & i & ")"
		End If

		' ファイルをフォルダに保存する
		objItem.SaveAs strFileName & ".txt", olTXT
	Next

	' 選択中ボックス内のボックス(サブボックス=サブフォルダ)一覧にforループを適用し、
	' 各サブボックスに対してこのルーチン自身を適用するという、再帰的処理
	For Each objSubFolder In objFolder.Folders
		If Not objFSO.FolderExists(strSavePath & objSubFolder.Name) Then  ' 保存フォルダに同名フォルダがなければ
			objFSO.CreateFolder strSavePath & objSubFolder.Name           ' 保存フォルダにサブフォルダを作成
		End If
		SaveFolderRecursive objSubFolder, strSavePath & objSubFolder.Name & "\"  ' サブフォルダに同じ処理を適用
	Next

End Sub

 
 
使い方としては、

  1. Outlookを起動する。
  2. Alt + F11でVBAエディタを開き、新規プロジェクトの標準モジュールで上記コードを書き込んで保存する。
  3. 保存したいメールボックスを選択し、Alt + F8のあと上記プロジェクト名を選択して実行する。


 とするだけです。
 また、リボンのユーザ設定でボタンを割り当てればワンクリックで動かせるようになります。*3
 複数のpstファイルをまたいで一発で保存することもできるかもしれませんが、調べていません。とりあえず上記マクロを、pstファイルの数だけ実行するという対応にしました。中途半端ですが・・・。
 なお、添付ファイルは無視です。


 実際やってみたら、メールの数があまりにも多い(1万通以上とか)メールボックスの場合、途中から「ファイルアクセス権のエラーのため保存できません」というメッセージが出て1通も保存ができなくなってしまいました。原因はよく分かりません。ググったら、そのエラーは常住アプリが作用して起きることがあるとかいう曖昧なことしか分かりませんでした。
 良く分からなかったので、いったん強制終了し、アイテム数がきわめて多いメールボックスについては、サブボックス単位でマクロを適用していくことにしました。結果、そこそこ手間がかかりました…が、求めていた「1通1ファイルのテキスト」が手に入ったので、よかったです。


 私はVBAを(ましてやOutlookで)使ったことがほとんどありませんが、上記のような超短いコードを写経しただけでも、なんか心理的なハードルがとても下がりました。「どういうオブジェクトがあって、どういうプロパティとメソッドがあるかを覚えていけばいいのか~」と、こう書いてしまうと当たり前のこと過ぎてナンセンスな感想なのですが、でもほんとにそんな感じでした。あと、VBAに限りませんが、一行一行自分で理解して、コメントを付けていくってのがけっこう勉強になるなと思いました。

 

*1:しかもExcelじゃなくてOutlookだし

*2:VBAのコード中ではFolderとなりますが。

*3:どこかのタブを開いてボタンが並んでいるところの端の方を右クリックし、リボンのユーザ設定で、新しいグループを作成してから、そのグループにマクロを挿入し、適当に名前を付ける。

学術研究費のクラウドファンディング 〜CrowdからTribeへ〜

 以下の記事をみてTwitterでシェアしようと思ったら要約が長くなりすぎたのでここにメモしておきます。単に概要を箇条書きしただけです。
 Crowdfunding and Tribefunding in Science – The Next Regeneration
 
 

  • 政府系の競争的な研究資金を取るのは大変で、研究者はその応募書類をつくるのにめちゃめちゃ労力を割いている。
  • 一方、そんだけ労力を割いても採用されないプロジェクトが多く、審査も完全ではないから、優れたアイディアが闇に消えてしまっている可能性もある。
  • そんななか、最近は学術研究の分野でも、クラウドファンディングによって研究費を集めるケースが増えてきている。
  • experiment.comなど、そのためのオンラインプラットフォームも出来ている。
  • 数十人・数十万円を集めれば成立するプロジェクトが多く、意外と成立している。
  • 意外と、大衆受けするテーマでなくてもプロジェクトが成立している。
  • しかし問題は、研究経費の一部を賄うレベルの案件ばかりで、研究者の人件費をカバーできるようなものはなかなか難しいから、長期的な研究プロジェクトのベースにはなりにくい。
  • そこで最近は、「ばらばらの個人」に資金提供してもらうcrowdfundingではなく、資金提供者にもプロジェクトのコミュニティメンバーとして深く長期的に関与してもらうtribefundingの考え方が台頭してきている。
  • イメージとしていうと、バラバラの個人との一時的な関係だと1万円ぐらい出してもらうのが限界だとしても、一緒にディスカッションしてプロジェクトを築き上げていくメンバーとしてなら10万円ぐらい出してくれるかもしれない。
  • また、こういう方式であれば、研究プロジェクト提案の査読についても、どっかの大学教授が適当に捌いていくのではなく、その研究に関心のある幅広い人達でディスカッションすることができ、内容をより良いものにすることもできる。
  • 草の根レベルで広い範囲から資金を募ることができるというクラウドファンディングの良さと、プロジェクトに関心がある人同士の長期的な共同関係をミックスすることで、学術研究におけるファンディングがもう少し進歩するかもしれない。

 
「クラウドファンディング」から「トライブファンディング」への進化については以下の解説記事を参考に。
Crowdfunding or Tribefunding? – Klugemotivation
 
 なるほどなぁと思いつつ、具体的なイメージはまだついてないです。
 ただまぁ、「ガチの共同研究者」としてでもなく、「投げ銭程度の寄付をする一般人」としてでもなく、その中間的なレベルで学術研究に関与するっていうあり方はあっても良いんだろうと思いますね。この世の中に、そういうレベルのモチベーションは確かにある程度ありそうで、モチベーションがあるならそれに見合ったおカネが動く可能性はある。意思決定のルールを明確にしておかないと活動が混乱するかもしれませんが。


 それはそれとして、河野太郎議員には、大学の事務ルールがどうのこうのみたいな細かい問題はさっさと片付けて、学術研究資金を「総額としてどんどん増やす」方向で太っ腹な活躍をして頂きたいと個人的には思ってます。