Sitting in an Armchair

取るに足らない勉強日記

NBER Working Paperが検索しづらいので自前のDB作った

経済学実証のワーキングペーパーとして有名なNBER WPを読み漁りたいと思ったんですが、せっかくWPごとに分野の情報が記録されているのに分野検索が若干やりづらいです。
それなら自前でDB作っちゃえば後が楽?と思ったので早速やってみました。

※この記事を書いた時からNBERのサイト構成が変わりました。つきましては現在このコードは使えなくなっています。
近々新サイト向けに新たにコード書こうと思っています

NBER Working Papers

全米経済研究所(NBER: the National Bureau of Economic Research)は1920年創立のアメリカの民間の経済学研究所で、経済学の実証分析のワーキングペーパーを現在までに17000本以上公開しています。
www.nber.org
個人的に、実証WPは後日トップジャーナルに載るようなネタから、経済学研究に対するcontributionは比較的薄いが分野上重要な政策論文まで含まれていて、経済学への貢献を最重要視?しているトップジャーナルと比べて幅があり、視野を広げるには非常に良いものだと思っています。
しかも政策論文であっても論文としての構造はかなりしっかりしているので、論文読みのトレーニングにもなります。トップジャーナルに載るものとの差を意識して読むのも有益かもしれないです。

経済学の諸フィールドとリンクしたProgram

また、NBERは約20の分野に対応したProgramに分かれており、各WPがそれぞれ1つ以上のProgram(=分野)に属しているという扱いになっています。
Programは以下の通り、経済学の主要なフィールドと対応しているように見えます。
www.nber.org

  • Aging (AG)
  • Asset Pricing (AP)
  • Children (CH)
  • Corporate Finance (CF)
  • Development Economics (DEV)
  • Development of the American Economy (DAE)
  • Economics of Education (ED)
  • Economic Fluctuations and Growth (EFG)
  • Environment and Energy Economics (EEE)
  • Health Care (HC)
  • Health Economics (HE)
  • Industrial Organization (IO)
  • International Finance and Macroeconomics (IFM)
  • International Trade and Investment (ITI)
  • Labor Studies (LS)
  • Law and Economics (LE)
  • Monetary Economics (ME)
  • Political Economy (POL)
  • Productivity, Innovation, and Entrepreneurship Program (PR)
  • Public Economics (PE)

Programを見ると、経済学のフィールドっぽいものから、特定のトピックを扱うものまで対象が様々あるようです。

複数のProgramsとのマッチングは内部検索ではできない

各分野に所属するWP一覧は、WP検索画面から"NBER Program"ごとの閲覧をクリックすれば可能です。

f:id:v_yezoensis:20200817154742p:plain
WP検索画面より。https://www.nber.org/papers.html

一方で、複数の分野のクロスサイトについては上手く絞り込んだ検索ができません。結局、キーワード検索するしかなくなってしまい、面白い論文を見逃しそうな感じがあります。まあ、各ページをしらみつぶしにみていけばいいのかもしれませんが…。

と、いうことで、検索できないなら…

自前でDB作れば好きなように検索できるのでは?

スクレイピングして自前のDB作っちゃった

というわけで、NBERのWPページ情報をスクレイピングしつつDBを作っていきます。
各WPページにはタイトル・著者・WP番号・日時・Programタグとabstractが同じ構造で掲載されています。

f:id:v_yezoensis:20200815003023p:plain
タイトル・著者・WP番号・公開日・Programなどの構造はどのページも共通(Edgeの開発者ツールより)

と、いうわけで、構造が共通ならスクレイピングにはもってこいです。なお、他には新しいWPの場合はアクセスリンク、すでにどこかにpublishされている場合はその書誌情報が載っていたりするのですが、この辺りのフォーマットが若干共通していなかったので今回は断念しました。この辺りはヒットしたWPのページを見るようにしましょう。

その前にまずスクレイピングの注意点

約10年前に似たことをやって逮捕された人がいたそうです…。
ja.wikipedia.org
図書館の検索システムをスクレイピングした結果、Webサイトが脆弱すぎた結果システムをダウンさせてしまい、偽計業務妨害の疑いで逮捕されてしまったそうです。

男性が実際に行っていたのは、蔵書検索システムの使い勝手に満足しなかったため自身で作成したクローラを実行し、蔵書検索システムから図書情報を取得することであった。クローラとは、自動的に情報を引き出しデータベースにまとめるプログラムであり、GoogleYahoo!等の検索エンジンなどでも利用されている。(中略)
このクローラは、同時には一回しかリクエストを送らず、受信後に間隔をおいてから次のリクエストを送信していた(1秒に1アクセス程度に調整)。これはクローラの動作としては「常識的」「礼儀正しい」[6]程度のものであり、応答を待たずに過大なアクセスを行うことで高負荷にさせる攻撃用のプログラムと異なる動作であった。
(出典:岡崎市立中央図書館事件 - Wikipedia

結局、攻撃性がないということで放免となったそうですが、下手すると業務妨害になってしまうかもしれないことは認識しておくべきですね。WPのメタデータ収集はこの男性がやろうとしていることとやや似ている節があります。どちらにせよ、サイトに過剰な負荷をかけないような配慮が必要です。

Pythonスクレイピングするときのパターン2つ(BeautifulSoupとSelenium

PythonでWebスクレイピングをするときには、主にBeautifulSoupとSeleniumという2つの方法があるようです。
僕のざっくりとした理解で言えば、BeautifulSoupはURLを指定したらそのHTMLを取得し、それをpython上で処理する感じ。SeleniumChromeを実際に立ち上げて、クリック動作をPythonで自動化する感じ。
前者の方が実際のブラウザ表示がないので高速というイメージですが、後者は実際にブラウザ表示させることでWebページからブロックされることが少ない気がします。今回のスクレイピングではどっちも使用してみましたが、メインではブロックでの中断が怖いというのと、そんなに時間を気にしないのでSeleniumを使っています。

範囲指定

まずは収集したいWPの範囲をWP番号で指定します。現在までのすべてのWPを収集しようとすると時間がかかりすぎるのと、若干フォーマットが変わっていたりするのもあり、最大でも2000年以降とかが常識的でいいかもです。
参考までに、2000年1月最初のWPはNo.7455、2010年6月から2020年5月までの10年間ならNo.16043- 27292の間になります。

今回は、NBERの直近100件のWPを取得してみることにします。ちなみに1件当たり3秒程度、100件取得には大体5分くらいかかります。10000件取得で大体一晩中かかる感じです。
直近100件を取得するためには、最新のWPの番号を知る必要があります。今回はBeautifulSoupで番号を取得してみます。

# 最新のNBER WPのpaper No.を取得(by BeautifulSoup)
import urllib.request
from bs4 import BeautifulSoup
html_newWP = urllib.request.urlopen('https://www.nber.org/new.html')
newest = BeautifulSoup(html_newWP, "html.parser").find('li', attrs={'class': 'multiline-li'}).find('a').get('href').replace('https://www.nber.org/papers/w','')
newest = int(newest)

# データ収集するWP No.の範囲を指定
# 最新WPの番号はnewestに格納済み
start = newest-99
end = newest

ここで、urllib.requestモジュールはURLを与えるとそのURLのHTMLを取得してくれるもので、urlopen()関数でHTMLを読み込んでくれます。
また、BeautifulSoupはHTMLを与えるとタグによって構造化された形に変換してくれます。そのオブジェクト内でfindメソッドを使うことで、特定のタグに含まれる要素を取得できます。その要素に対してgetメソッドを使うと属性を、textメソッドを使うと内容を返してくれるようになっています。

# urllib.request.urlopen()で特定のURLをオープンしHTMLを取得
html = urllib.request.urlopen('https://www.nber.org/new.html')

# readメソッドで読み込み、decodeメソッドで文字コード変換。
# 20行だけ読み込んでみる
for n in range(20):
    print(html.readline().decode().rstrip())


<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"  "http://www.w3.org/TR/html4/loose.dtd">
<html ><head><title>New This Week</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<!-- BEGIN 2009_redesign/inc/top.html -->
<script type="text/javascript">
        var djConfig = {
            parseOnLoad: true,
            isDebug: false
        };
</script>
<script type="text/javascript"
        src="/2009_redesign/dojo-1.3/dojo/dojo.js"></script>
<link rel="stylesheet" href="/2009_redesign/dojo-1.3/dijit/themes/tundra/tundra.css">
<link rel="stylesheet" href="/2009_redesign/2009.css" type="text/css">
<link rel="image_src" href="//www.nber.org/img_2009/NBER_logo_2014.jpg" / ><!--formatted-->
<script language="JavaScript" type="text/javascript" src="/2009_redesign/top.js"></script>
<!--style applied-->

<!-- Google Analytics -->
<script>
# BeautifulSoupでHTML構造に変換、findメソッドで該当する最初の要素を取得
# attrs属性でclassやidを指定
link = BeautifulSoup(html, "html.parser").find('li', attrs={'class': 'multiline-li'}).find('a')

# 最初の<a>タグの要素を取得したので、URLとテキストを取得してみる
print('URL:',link.get('href'))
print('TEXT:',link.text)


URL: https://www.nber.org/papers/w27707
TEXT: 
Measuring Customer Churn and Interconnectedness

ここで、w*****はWP番号を示しているので、URLのw以前を消去すればWP番号を得られます。ここもスクレイピングがやりやすいポイントの一つですね。ちなみにこれが規則的な番号になっていない場合は、よりWebページ上のクリック動作を再現する必要性が高まり、Seleniumの使い勝手がよくなる印象です。面倒なのには変わりないですが、、、。

いよいよスクレイピング

次に、Seleniumにより個別WPのメタデータを収集していきます。
seleniumによるスクレイピングには、chromedriver_binary, seleniumのwebdriverが必要です。この辺りのセットアップについてはもっと詳しいブログが山ほどあるのでそちらを参照してくださいね。Chromeは最新バージョンではいけないとか、いろいろ面倒なことがあった気がします。

options = webdriver.ChromeOptions()で必要なオプションを保存したうえで、そのオプションに従ってdriver = webdriver.Chrome(options=options)でChromeを(物理的・自動的に)開きます。そのあとはBeautifulSoupと同じように、ページにアクセス→要素を取得→記録、を繰り返していきます。BeautifulSoupで行っていたHTMLの取得は、Seleniumでは代わりに実際にページにアクセスすることで実現されます。Seleniumでは、ページアクセスにdriver.get(url)、特定タグを探すにはfind_element_by_idやfind_elements_by_css_selector等のメソッド、要素の取得にはtextやget_propertyメソッドを使います。
また、この操作を繰り返す過程で、前述の業務妨害への予防措置としてページに負担をかけないようにtime.sleep()で待ち時間を設定しておくことが一般的に推奨されています。

DB構築は、最終的にはpandas.DataFrame形式にすることを見据えて二次元リスト形式にしておきます。

# seleniumのchrome立ち上げ
import chromedriver_binary
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = webdriver.ChromeOptions()
driver = webdriver.Chrome(options=options)


# スクレイピング(by selenium)
paperlist = []
error = []

for elem in range(start, end+1):
    url = 'https://www.nber.org/papers/w' + str(elem)
    try:
        driver.get(url)
        title = driver.find_element_by_id("mainContentTd").find_elements_by_css_selector('h1')[0].text
        author = driver.find_element_by_id("mainContentTd").find_elements_by_css_selector('h2')[0].text
        number = driver.find_element_by_id("mainContentTd").find_elements_by_css_selector('p b')[0].text
        number = number[number.rfind('No.')+4:]
        month = driver.find_element_by_id("mainContentTd").find_elements_by_css_selector('p b')[1].text
        month = month[month.rfind('in')+3:]
        abstract = driver.find_element_by_id("mainContentTd").find_elements_by_css_selector('p')[1].text
        genres = [link.get_property("href")[link.get_property("href").rfind('/')+1:link.get_property("href").find('.html')] for link in
                  driver.find_element_by_id("mainContentTd").find_elements_by_css_selector("p b")[2].find_elements_by_css_selector("a")]
        paperlist.append( [title, author, number, month, url, genres, abstract] )
        time.sleep(1)
    except Exception as e:
        error.append([url, e])

# listからpandas Dataframeへの変換
import pandas as pd
paperdata = pd.DataFrame(paperlist)
paperdata.columns = ['Title', 'Author', 'number', 'Month', 'url', 'genres', 'abstract']

display(paperdata)

f:id:v_yezoensis:20200817173020p:plain

検索は関数定義すればらくちん

あとは、自分のやりたいようにDBの絞り込みをかければOKです。頻出の検索メソッドがあるなら、自前の関数を作ってみるのもいいかもしれません。
今回は複数ジャンルの同時検索を定義してみましたが、ぶっちゃけいらなかった気も。

# 複数ジャンル検索
def search_by_genre_all_included(genrelist,varlist):
    result = paperdata[paperdata['genres'].apply(lambda x: all(gs in x for gs in genrelist))][varlist]
    print(len(result), 'papers hit')
    display(result)
def search_by_genre_one_included(genrelist,varlist):
    result = paperdata[paperdata['genres'].apply(lambda x: any(gs in x for gs in genrelist))][varlist]
    print(len(result), 'papers hit')
    display(result)

# 例:IO(Industrial Organization), HC(Health Care), PR(Productivity, Innovation, and Entrepreneurship)
# のうち一つでもジャンルに含まれているものを検索
search_by_genre_one_included(['IO','HC','PR'],['Title','url', 'genres'])

f:id:v_yezoensis:20200817173057p:plain

感想

やっぱり自前にDB作ってしまうと後が楽ですね。スクレイピングの練習としてもGoodでした。
ただこの作業、費用対効果が高かったかといわれると微妙なところです。同じことする人、ほとんどいないんじゃなかろうか…。
あと、ipynbのoutputをはてなブログでうまく公開する方法ってないのでしょうか。pandas.DataFrameをスクショしている瞬間はかなり悲しい気持ちでした。

全コード@Gist