orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

2017年を振り返って

年末にCivilization Revolution 2にハマってしまい時間がなくなりました。

2017年を振り返って

2016年末に出版されたPythonクローリング&スクレイピングが高い評価をいただけていて、とてもありがたく思っています。

また、書籍に関連してコミュニティでの発表機会や、お仕事の話などもいただけて良い経験になりました。

2016年の振り返りに書いていたとおり、2017年は比較的開発や勉強に時間を使えて充実していました。

プライベートでは2人目の子供が産まれてから疲れやすくなっており、夜はスマホのゲームをしてMP回復して終わるみたいな日が増えてしまっていますが、調子のいい時間を大切にしていきたいです。

2018年に向けて

2018年は変化の年にしたいと思います。

最近は、Twitterで勧められているのを見かけたSpotlight Englishを通勤時間帯に歩きながら聴いているので、これは継続していきたいです。

今後ともよろしくお願いいたします。

【1万部突破】Pythonクローリング&スクレイピングの発売から約1年

先月ツイートしましたが、Pythonクローリング&スクレイピングは第5刷となり、累計発行部数が1万部を突破しました。

評価

1万部突破にあたって http://scraping-book.com/ を更新する際に、ブログでの書評をまとめたのですが、とても良い評価をいただけていて嬉しく思います。

Amazonのカスタマーレビューは13件も書いていただき、★4.3と高い水準が継続していて本当にありがたいです。

Rubyによるクローラー開発技法」のヒットやデータサイエンスにおけるPython人気の高まりという良いタイミングで企画をいただけたことに加え、締め切り前にScrapyがPython 3に対応したのも幸運でした。

また、書籍が良い内容になるには、レビュアーとして参加いただいた皆様の協力も不可欠でした。関係者の皆様にお礼を申し上げます。

振り返りの意味も込めて、この1年での本書を取り巻く状況の変化のうち、主なものを述べていきます。

クロール対象のサイトの変化

クローラー本の宿命ですが、クロール対象のサイトの変化により、書籍で掲載しているサンプルのコードが動かなくなってしまうことがあります。

把握している範囲の変更は、以下のサポートページに補足情報として掲載しています。

サポートページ:Pythonクローリング&スクレイピング ―データ収集・解析のための実践開発ガイド―:|技術評論社

gihyo.jpでCDNが導入されたことで、2章で使っているurllibのUser-Agentに対して403を返すようになってしまったのは結構初期段階なので辛いですが、代わりにサンプルサイトをご利用ください。

今のところ把握している範囲では、サイトが消えてなくなったりはしていないので、まだマシだとは思っています。

PhantomJSのメンテナー引退

書籍ではJavaScriptを使ったページへの対応として、PhantomJSを使っていましたが、今後はChromeFirefoxのヘッドレスモードが主流になりそうです。

以下の記事は、PhantomJSの代わりにHeadless Chromeを使って本書のサンプルコードを動かしてみたものです。

SPAの採用が進むにつれてJavaScriptを使ったページへの対応は重要になっていくので、もっとページを割いても良かったかなと思います。

AWS LambdaのPython 3対応

発売当時のAWS LambdaはPython 3系に対応していなかったこともあり、クローラーの運用としてLambdaは紹介しませんでしたが、2017年4月にLambdaがPython 3に対応しました。

Lambdaを使えばサーバー管理が不要になるので、可能な場合には積極的に使っていきたいサービスです。以下の記事ではLambdaに加えてAWS Fargateを使い、EC2を管理せずにクローラーを運用できることを示しています。

その他

他にも書籍ではページ数の都合で書けなかったことを補足的に記事にしておきました。

今後ともよろしくお願いいたします。

AWS FargateとLambdaでサーバーレスなクローラー運用

これはWebスクレイピング Advent Calendar 2017の7日目の記事です。こんな感じでAWS FargateとAWS Lambdaを使ってサーバーレス(EC2レス)なクローラーを作ります。

f:id:mi_kattun:20171206222317p:plain

この記事はFargateでのクローリング処理にフォーカスしており、クロールしたHTMLをS3に保存するところまでを主に解説します。Lambdaの方はおまけ程度の扱いで、スクレイピングしたデータの扱い(データベースへの格納など)はスコープ外です。

長くなったので目次です。

背景

クローラーを稼働させるのにサーバー管理をしたくないという思いがあります。かと言ってScrapinghubのようなSaaSは、ロケーション・実装の自由度やサービス継続性の面から採用しづらいこともあります。

サーバーレスと言えばLambdaが代表格です。Lambdaでクローラーを作ろうとする取り組みは過去にもあります。

ただ、クローラー*1の処理のうち、スクレイピング処理はいいとしても、クローリング処理はあまりLambda向きのワークロードではないと考えています。

クロール対象のサーバー負荷軽減のためにクロール間隔を空けることを考えると、Lambdaのスケーラビリティはあまりメリットになりません。Lambdaをn秒間隔で呼び出してくれるような仕組みはない*2ので、クロール間隔を空けるためにはLambdaの実行中にSleepする必要があります。

LambdaはCPUを使っていなくても関数の実行時間で料金が決まるので、Sleepしている間やWebページのダウンロードを待っている間も課金対象です。このクローラーの性質は、まさに以下の記事に書かれているLambdaに不向きなアプリケーションの性質に合致します。

  • 1: Lambda functionの実行時間のうち、ネットワークI/O時間が支配的である
  • 2: Lambda functionの実行終了を同期的に待たなければならない
  • 3: 複数のレコードをLambda functionの引数に渡すことができない*3

コスト効率の悪いLambdaアプリケーションの性質に関する考察 - ゆううきブログ

さらに、Scrapyのような1プロセス内での非同期実行を前提とした開発効率の良いフレームワークがある中、細切れのLambda関数を書いていくのはダルいという思いもあります。

AWS Fargateの登場

そんなわけで2017年11月にこのAdvent Calendarに登録した時はクローリング処理をサーバーレス(EC2レス)で実行するためにAWS Batchを使うことを考えていました。

しかし先日のAWS re:Invent 2017でAWS Fargateが颯爽と登場したので、早速試してみます。AWS FargateはDockerクラスターを構築するサービスであるAmazon ECSにおいて、EC2を管理することなくクラスターを使えるサービス(クラスターの一形態)です。

aws.amazon.com

クローラーの場合、FargateにしたところでLambdaのコストモデルの問題が解消するわけではありませんが、プロセスの実行が長時間に渡ることを前提としたScrapyのようなフレームワークを使いやすいというメリットがあります。

LambdaでもFargateでも、クロール対象サイトを複数にして同時にクロールすればネットワークI/Oを多重化できます。ですが、サイトごとのクロール間隔の調整はそれなりに面倒な処理なので、Fargateを使うことで既存のフレームワークで提供されている仕組みに乗っかれるのはメリットです。

クローラーの構成

冒頭の構成図を再掲します。

f:id:mi_kattun:20171206222317p:plain

Scrapyはクローリングしながらスクレイピングするアーキテクチャですが、以前も書いたように、これはあまり良いアーキテクチャではないように思います。そこで、クローリングとスクレイピングを分離し、Scrapyではクローリングのみを行い、HTMLを丸ごとAmazon S3に保存します。

S3を間に挟むとスクレイピングに失敗したときや追加の情報を取得したくなったときでも、相手のサーバーに負荷を与えずにやり直しできて安心です*4。実運用ではS3のライフサイクルポリシーを使って1ヶ月後にオブジェクトを消すなどすると、再実行可能性とデータ量の抑制を両立できます。

やってみる

クローリング処理はPython 3.6とScrapyで作成します。途中でPython 2系では動作しないライブラリを使います。

これから作成するScrapyのSpiderのソースコードは以下の場所に置いてあります。

github.com

以下は前提とします。

  • AWSのアカウントを持っていること
  • 適当なS3バケットを持っていること
  • ローカル開発環境において、そのS3バケットへの書き込み権限とAmazon ECRへの書き込み権限を持つユーザーでAWS CLIを設定済みであること
  • ローカル開発環境ではPython 3の仮想環境内で作業していること

1. ScrapyのプロジェクトでSpiderを作る

まずはローカル開発環境でScrapyのSpiderを作ります。この記事では、Pythonクローリング&スクレイピングサンプルファイルの中にある、6.7節の食べログのSpiderを改変して使います。

DOWNLOAD_DELAYなどの設定は適切にしておきましょう。

2. Scrapy S3 Pipelineをインストールする

Scrapyでクローリングのみを行い、HTMLを丸ごとS3に保存するのはよくあるパターンなので、Scrapy S3 Pipelineというライブラリにしてみました。これをインストールします。

(venv) $ pip install scrapy-s3pipeline

Scrapy S3 Pipelineはスクレイピングして得られたItemをJSONLines形式でS3に保存するItem Pipelineです。Scrapyの通常のFeed ExportsもS3への保存に対応していますが、S3ではファイルへの追記ができない都合上、Spiderの実行が終わってから大きな1つのファイルをS3に保存します。一方Scrapy S3 Pipelineは、アイテムをチャンクごと(デフォルトで100アイテムごと)に区切ってS3に保存します。これによって、S3のイベント通知を受けてLambdaでスクレイピングする構成を取りやすくなります。

また、Gzip圧縮をサポートしているのも特徴です。同じようなHTMLが複数含まれるファイルは圧縮率が高くなるので、スクレイピング再実行のためにHTMLを残しておきたい場合には効果的です。

3. Scrapy S3 Pipelineをプロジェクトに追加する

プロジェクトのsettings.pyに以下の設定を追加します。S3PIPELINE_URLのバケット名 (scraping-bookの部分) はご自身の所有するS3バケットに変更してください。

ITEM_PIPELINES = {
    's3pipeline.S3Pipeline': 100,
}

S3PIPELINE_URL = 's3://scraping-book/{name}/{time}/items.{chunk:07d}.jl.gz'

なおS3PIPELINE_URL内の変数は以下のように置き換えられます。

  • {name}: Spider名
  • {time}: Spiderの開始時刻
  • {chunk}: チャンクに含まれるアイテムの開始番号(0, 100, 200, ...)。上記ではFormat Stringを使ってゼロパディングしている。

Spiderのコールバックメソッドではスクレイピング処理を行わず、単にs3pipeline.Pageオブジェクトをyieldするのみとします。Pageクラスはurl, body, crawled_atの3つのフィールドを持ち、Page.from_response(response)とするだけでこれらの値が埋められます。

# ...

from s3pipeline import Page

# ...

class TabelogSpider(CrawlSpider):

    # ...

    def parse(self, response):
        yield Page.from_response(response)

ここまでできたら以下のコマンドを実行し、問題なくクローラーが動作していればOKです。

(venv) $ scrapy crawl tabelog

正常に動作していれば以下のようにS3にオブジェクトが作成されるはずです。

f:id:mi_kattun:20171206223934p:plain

4. ScrapyのプロジェクトをDockerizeする

作成したSpiderをDockerコンテナ内で実行できるようにします。

以下の内容でDockerfileを作成します。

FROM python:3.6.3

WORKDIR /app

COPY requirements.txt .

RUN pip install -r requirements.txt

COPY . .

CMD ["scrapy", "crawl", "tabelog"]

.dockerignoreファイルも作成しておきます。

venv/
.scrapy/
.git/

以下のコマンドで、Dockerイメージをビルドして実行できればOKです。

$ docker build -t serverless-crawler .
$ docker run --rm -it serverless-crawler

通常Dockerコンテナ内ではAWSのクレデンシャルを取得できないので、botocore.exceptions.NoCredentialsError: Unable to locate credentials などのエラーが発生するはずです。Fargateでの実行時にはIAM Roleで取得できるようになるので特に問題ありません。Ctrl-Cを2回押してSpiderの実行を終了しておきます。

5. Amazon ECRにリポジトリを作成する

さて、ここからはAWSのマネジメントコンソールで作業します。2017年12月時点ではAWS Fargateは北部バージニアリージョン(us-east-1)のみに対応しているので、以降の手順ではすべて北部バージニアリージョンを使います。

Amazon ECSの管理画面の「リポジトリ」から serverless-crawler という名前のリポジトリを作ります。

6. DockerイメージをAmazon ECRにpushする

リポジトリ作成時に手順が表示されるはずですが、以下のようにログインして先ほどビルドしたDockerイメージをAmazon ECRにpushします。

ログイン:

$ $(aws ecr get-login --no-include-email --region us-east-1)
Login Succeeded

タグ付けとプッシュ(<AWS ID>はご自身のIDに置き換えてください):

$ docker tag serverless-crawler:latest <AWS ID>.dkr.ecr.us-east-1.amazonaws.com/serverless-crawler:latest
$ docker push <AWS ID>.dkr.ecr.us-east-1.amazonaws.com/serverless-crawler:latest

f:id:mi_kattun:20171206224109p:plain

7. IAM Roleを作成する

ECSのタスクからS3にPUTできるよう、EC2 Container Service Task (ecs-tasks.amazonaws.com) をTrusted EntityとするIAM Roleを作成しておきます。 ecsTaskServerlessCrawler という名前のロールを作りました。

本来はポリシーを書いて必要最低限の権限のみを割り当てるべきですが、手抜きしてAWS管理ポリシーAmazonS3FullAccessを割り当てました。

8. ECSクラスターを作成する

ここからいよいよAmazon ECSの管理画面での作業です。「クラスター」→「クラスターの作成」から、Networking Only (Powered by AWS Fargate) のクラスターを作成します。defaultという名前にしました。

「Getting Started with Amazon Elastic Container Service (Amazon ECS) using Fargate」というウィザードもありますが、今回は使いません。このウィザードを使うとECSのサービスも一緒に作成されますが、バッチ処理のように実行完了したら終了したいタスクはクラスターとタスク定義だけあれば良く、サービスは不要なためです。

f:id:mi_kattun:20171206224227p:plain

9. ECSタスク定義を作成する

「タスク定義」→「新しいタスク定義の作成」から、実行するタスクの雛形であるタスク定義を作成します。

  • ステップ 1: Select launch type compatibility
    • Launch Type Conmpatibility: Fargate
  • ステップ 2: Configure task and container definitions
    • タスク定義名: serverless-crawler
    • タスクロール: ecsTaskServerlessCrawler (7で作成したもの)
    • Task execution role: ecsTaskExecutionRole (初回に自動作成されるはず)
    • Task memory (GB): 0.5GB
    • Task CPU (vCPU): 0.25 vCPU
    • コンテナ定義
      • コンテナ名: serverless-crawler
      • イメージ: <AWS ID>.dkr.ecr.us-east-1.amazonaws.com/serverless-crawler:latest (6でpushしたもの)
      • メモリ制限: ソフト制限 128MB
      • 環境変数など必要なものがあればここで追加する

10. タスクを実行する

クラスタdefaultの「タスク」→「新しいタスクの実行」か、タスク定義serverless-crawlerの「アクション」→「タスクの実行」からタスクを実行します。

Launch typeでFARGATEを選択し、適切なVPCとサブネットを選択して実行します。この時点でタスクのロールやコンテナの環境変数などを上書きすることも可能です。

f:id:mi_kattun:20171206224516p:plain

実行開始して1分ほど待つとRUNNING状態になり、ログを確認できるようになります。 ただ、ECSの管理画面に表示されるログは見づらいので、CloudWatch Logsで見たほうがわかりやすいです。

5分ほどでSpiderの実行は終了し、S3にアイテムが生成されているのが確認できます。

おまけ: Lambda関数を作成する

スクレイピング処理は、こんな感じのLambda関数(Python 3.6)を作成し、S3へのオブジェクトの作成をトリガーとして呼び出されるようにします。Lambdaに関しては世の中に多くの情報があるのでここでは詳しく解説しませんが、以下の点には気をつけてください。

  • サードパーティライブラリ(この例ではlxmlとcssselect)は一緒にパッケージングする。
  • S3からファイルを取得できるようIAM Roleを設定する。
  • 同じファイルへのイベントが複数来ることがあるので、処理がべき等になるようにする。
  • LambdaをVPC内で実行する場合はLambdaからS3にアクセスできるようVPCエンドポイントを作るなどする。
import io
import json
import gzip

import boto3
import lxml.html


def lambda_handler(event, context):
    """
    Lambdaから呼び出されるエントリーポイント
    """
    for record in event['Records']:
        bucket_name = record['s3']['bucket']['name']
        object_key = record['s3']['object']['key']
        process_object(bucket_name, object_key)


def process_object(bucket_name, object_key):
    """
    1つのS3オブジェクトを処理する。
    """
    s3 = boto3.client('s3')

    for page in read_pages(s3, bucket_name, object_key):
        scrape_from_page(page)


def read_pages(s3, bucket_name, object_key):
    """
    S3オブジェクトからページをyieldするジェネレーター
    """
    use_gzip = object_key.endswith('.gz')

    bio = io.BytesIO()
    s3.download_fileobj(bucket_name, object_key, bio)
    bio.seek(0)
    f = gzip.GzipFile(mode='rb', fileobj=bio) if use_gzip else bio

    for line in f:
        page = json.loads(line)
        yield page


def scrape_from_page(page):
    """
    1つのページからlxmlなどを使ってスクレイピングする。
    """
    root = lxml.html.fromstring(page['body'])
    restaurant = {
        'url': page['url'],
        'name': root.cssselect('.display-name').text_content().strip(),
    }
    # スクレイピングしたデータのDBへの保存などを行う

まとめ

AWS Fargateを使って、EC2の管理をすることなくDocker化されたバッチ処理クローラー)を実行できました。バッチ処理に合わせてクラスタを起動・終了しなくてもクラスタ実行時間のみが課金対象となるので楽です。Lambdaのような制限も少なく、既存の処理も移行しやすそうです。

タスクの起動に時間がかかるのはやや気になりますが、ある程度長時間実行するクローラーであれば影響は少ないでしょう。今回はDebianベースのDockerイメージを使ったので、Alpineベースのイメージにするなど、イメージサイズを減らすことで改善するかもしれません。

他にもやり残した点として、クローラーのグレースフルな停止があります。ScrapyのクローラーをDocker化するとコンテナの停止時(マネジメントコンソールからECSのタスクを停止した時)にSIGTERMが送られて一瞬で終了してしまうので、SIGINTによるグレースフルなクローラーの停止に対応させたいです。

Pythonクローリング&スクレイピングはおかげさまで1万部を超えるヒットとなりました。出版当時の2016年12月はLambdaのPython 3対応もなかった時代で、7章の運用ではオーソドックスにEC2内のCronでプロセスを実行していましたが、Fargateの登場によってEC2なしで運用できるようになりました。Fargateが東京リージョンに来るのを楽しみにしています。

scraping-book.com

*1:ここでのクローラーは数時間に渡って1つのサイトを数万ページほど巡回するものを想定しています。また、クローリング処理とスクレイピング処理を分けて実行するクローラーを前提としています。

*2:筆者の知る限り。

*3:引用者注:クロール間隔を空けることを考えるとI/O多重化して複数のリクエストを送ることはできないため。

*4:HTTPキャッシュでも似たようなことは実現できますが、スクレイピングの再実行の効率やクロール結果の世代管理などを考えると、明示的に保存しておくほうがやりやすいと考えています。

言語実装パターンを読んだ

言語実装パターン ―コンパイラ技術によるテキスト処理から言語実装まで

言語実装パターン ―コンパイラ技術によるテキスト処理から言語実装まで

「言語実装」と聞くとコンパイラの本かと思いますが、本書は「言語アプリケーション」を実装するための本です。言語アプリケーションには設定ファイルの読み取り、データの読み取り、コード生成、コード変換器、インタプリタなど、入力ファイルを処理・解析したり、変換したりするアプリケーションが含まれます。

このようなアプリケーションはだいたい同じような構成になり、以下のような要素を組み合わせて実装することになりますが、各要素の実装パターンが数種類紹介されていて、メリット・デメリットを考慮して適切なものを選択できるようになっています。

(私が知らないだけの可能性は高いですが)このような知見がまとまった書籍は他にないような気がします。言語の変換を行う際に、実装の指針となりとても役立ちました。

ANTLR 4の学習

言語アプリケーションの実装において、すべてを手で実装するのは現実的ではなく、特に構文解析にはパーサージェネレーター(あるいはパーサーコンビネーター)を使うのが一般的です。本書では著者のTerence Parrさんが開発しているパーサージェネレーターのANTLRを使います。

原著が2009年に執筆された本書ではANTLR 3が使われていますが、現在一般的なのは2013年に公開されたANTLR 4です。ANTLR 4はフルスクラッチで書き直され、考え方が大きく変わっています。木構造を生成する文法が削除されるなど、文法定義も互換性がありません。

このため、ANTLR 4で書籍内のサンプルコードを動かすことはできません。おとなしくANTLR 3で動かせばいいのですが、せっかくだし最新のバージョンを使いたかったのでANTLR 4の勉強もはじめました。

この本はANTLR自体を解説した本ではありません。ANTLR 4のリファレンスはGitHubで公開されていますし、文法定義の説明もWebに多くありますが、生成されたパーサーの使い方に関する情報があまり見当たらないように思いました。

ANTLR 4の学習には、(英語ですが)同じくTerence Parrさんが書かれたThe Definitive ANTLR 4 Referenceを読むのがオススメです。タイトルにReferenceとありますが、リファレンスは一部だけで、ANTLR 4の使い方が丁寧に解説されています。

The Definitive ANTLR 4 Reference by Terence Parr | The Pragmatic Bookshelf

以降では、主に参考にした箇所についてコメントします。

中間表現の木構造のパターン

4章「中間形式木の構築」に登場するパターンは、中身は簡単なことなのに難しい名前がついていてわかりにくく感じたので、5章「木の走査と書換え」に登場するパターンとの関連性と合わせて図示してみました。

f:id:mi_kattun:20171119125054p:plain

ちなみにANTLR 3では文法定義にアクションとして抽象構文木を生成するコードを書いたり、木文法で訪問器を生成したりしていたようですが、ANTLR 4が生成するパーサーでは構文解析木が自動的に構築されるように変更されました。外部訪問器の基底クラスも生成されるので、抽象構文木は必要なら外部訪問器を使って自分で生成します。

この方が文法定義がスッキリして分かりやすいコードになるので、良い変更だと思います。

ちなみにパターン15「木パターン照合器」についても、ANTLR 3にあった木書換えの文法はなくなったので、ANTLR 4では代わりに ParseTreePatternを使うようです*1

記号表のパターン

6章「プログラム記号の記録と識別」と7章「データ集合体のための記号表管理」に登場する記号表は以下のライブラリとして利用でき、助けられました。

github.com

コード変換のパターン

11章「コンピュータ言語の変換」に登場するパターン29「構文主導変換器」とパターン30「規則方式変換器」の違いがちょっとわかりづらかったです。構文ベースで変換するのが前者で、規則ベースで変換するのが後者ということだと思います。しかし、パターン30の例は変換規則DSLとしてANTLRの文法定義を使い、アクションでprint文を使って出力するというもので、これはパターン29でもあるのではないかと感じました。

パターン31「目的構成体ごとに固有の生成器クラス」と12章「テンプレートを使ったDSL生成」との関連もよくわからなかったです。パターン31の最後に「toString()の中で文字列を生成する代わりにテンプレートを使うこともできます。しかしながら、その場合は、特性の生成器クラスはやめてテンプレートだけを使うようにしたほうが良いでしょう。」という説明がありますが、12章はパターン31をより高度にしたものという理解で良いのでしょうか。12章をパターン32として、11章のパターンと比較する説明があると良かったと思います。

まとめ

わかりにくいと書いたところもありますが、私の読み込みが足りないだけとも言えます。全体的に見れば最初に書いたように実装の指針となる良書でした。もし叶うならば、ANTLR 4で書き直されたバージョンを読んでみたいです。

*1:まだ試してないので試したい。

可視化のお供に「PythonユーザのためのJupyter[実践]入門」

PythonユーザのためのJupyter[実践]入門を頂きました。ありがとうございます。そして出版おめでとうございます。

PythonユーザのためのJupyter[実践]入門

PythonユーザのためのJupyter[実践]入門

まず明らかにしておくと、私は日常的にデータ分析を行っているわけではなく、主にWebプログラミングのためにPythonを使っています。 PyData界隈でJupyter Notebookが広く使われていることは存じていますが、個人的には通常のインタラクティブシェルのほうが手に馴染むので好んで使っています。 このため、本書のメインターゲットではないような気がしますが、その視点からレビューします。

全体の構成

目次を見るとわかりますが、半分以上のページ(4章*1から7章)がMatplotlibとBokehを使った可視化の解説に割かれています。 これらの章はフルカラー印刷で、豊富なグラフの作成例をパラパラとめくって確認できます。

表題のJupyterについても基本的な導入・使い方に加え、設定のカスタマイズやクラウド環境での利用などの解説があり、なんとなく使っていたものをしっかりと手に馴染む道具へと昇華させられると思います。

というわけで、以下のような方にオススメです。

  • MatplotlibやBokehを使って可視化する必要があり、リファレンスを手元に置いておきたい方
  • Jupyter Notebookの使い方をしっかりと理解したい方

以下、印象的だった章に触れます。

第1章 Jupyter Notebookを導入しよう

WindowsmacOSにおけるAnacondaを利用したJupyter Notebookの導入方法が丁寧に解説されています。 condaコマンドを利用した仮想環境の扱い方や、Matplotlibでハマりがちな日本語フォントのインストールについても解説があるので安心です。

conda環境という言葉が説明なく出てくるのがちょっと気になりましたが、仮想環境と同じものという認識でよいのでしょうか。

第2章 Jupyter Notebookの操作を学ぼう

Jupyter Notebookの操作やメニュー、マジックコマンドなどについて一通りの解説があります。 なんとなくJupyter Notebookを起動して使っているだけではイマイチわかりづらい、セルの実行状態やモード、.ipynbファイルの仕組みや自動保存などについて丁寧に説明されています。

Jupyter Notebookを使っていると、キーボードショートカットを使いこなさないと思い通りに編集できないと感じます。 ヘルプからショートカット一覧を参照できるものの、数が多いので、本書ではよく使うものに絞って解説されているのがありがたかったです。

最後にJupyter Notebookの共有方法という話があります。 仮想環境にインストールしたライブラリを前提とした.ipynbファイルを共有して、同じ環境・実行結果を再現するにはどうするのだろうというところが若干気になりました。 .ipynbファイル内にはrequirements.txtに相当する内容は含まれてなさそうですし、コメントに書くのでしょうか。 それとも一般的には実行済みの結果さえ共有できれば気にならないものなのでしょうか。

第4章 Matplotlibでグラフを描画しよう

Matplotlibによるさまざまなグラフの作成方法が解説されています。 実際に意味のあるデータをサンプルとして使用しており、このような分析をするときにはこのグラフが適しているといった説明があるので、わかりやすいです。

第5章の最後に解説がありますが、Matplotlibには手続き型のインターフェイスオブジェクト指向インターフェイスの2種類が用意されています。 サンプルコードを書く人によって使うインターフェイスが異なり、混乱を招くことがあります。 本書ではPythonらしいオブジェクト指向インターフェイスが推奨されていて、こだわりを感じます。

第6章 Bokehでグラフを描画しよう

Bokehはブラウザ上での表示を前提とした可視化ライブラリです。Jupyter Notebookと相性がよく、後発である分Matplotlibよりも洗練されています。

最初にBokehでは高・中・低と3つのレベルのインターフェイスがあるという解説があります。 Matplotlibの章と同様にさまざまなグラフの作成例が収められており、それぞれのグラフについてまず高レベルのインターフェイスで簡単に作成できることを確認した後、中レベルのインターフェイスで細かなカスタマイズを行うという構成になっています。 このため、自分が作成したいグラフのカスタマイズ度合いによって、どのインターフェイスを選択すべきかわかりやすいです。

第9章 クラウド上でJupyter Notebookを使おう

Google Cloud Platform (GCP) とMicrosoft Azure上でJupyter Notebookを利用できるサービスの紹介です。 これらのサービスを使えばローカルで環境を構築する手間すら不要です。特にGCP上で使えるCloud Datalabでは、BigQueryなどのGCP上のサービスと連携できて便利そうです。

Appendix

付録と言いながらも、Jupyter Notebook上でインタラクティブな入力を可能にするipywidgetsや、Jupyter Notebookを発表用のスライドに変換する方法など、興味深いコンテンツが収められています。

最後に

本書の「はじめに」には以下の記述があります。

Jupyter Notebookは人気のツールであり、インターネット上には既に可視化に関する多くのドキュメントやサンプルコードが存在します。しかし私たちはJupyter Notebookを日々利用しながら「Jupyter Notebookの活用とデータ可視化における実践例や知見が集まった場所が少ない」と考えていました。

まさにこの課題を解決する内容になっており、同じような課題を感じている方にはピッタリの書籍と言えます。 これからJupyter Notebookを使った可視化を始める方にとっても、必要な知識を短時間で効率よく学べる一冊です。 興味があれば、ぜひお手にとってみてください。

PythonユーザのためのJupyter[実践]入門

PythonユーザのためのJupyter[実践]入門

*1:正確には3-10節

ElementTreeやlxmlで名前空間を含むXMLの要素を取得する

PythonElementTreelxmlを使って名前空間つきのXMLから要素を取得しようとしても、思い通りに取得できないことがあります。これはよくあるハマりどころですが、あまりまとまった情報がないのでまとめておきます。

Python 3.6.0で検証しました。

目次:

解決したい問題

まず前提としてXML名前空間について確認した後、解決したい問題について述べます。

前提知識: XML名前空間

1つのXML文書に複数の語彙を混ぜた場合、タグ名などが衝突してしまう可能性があるので、それらの意味を明確にするのが名前空間です。

XML名前空間URI(ここではhttp://www.w3.org/2005/Atom)で表され、XML文書内では名前空間に属する要素や属性を接頭辞(ここではatom:)をつけて表します。

<atom:feed xmlns:atom="http://www.w3.org/2005/Atom">
...
  <atom:title>orangain flavor</atom:title>
...
</atom:feed>

xmlns属性でデフォルト名前空間として指定した名前空間は接頭辞が不要になるので、以下のようにも書けます。

<feed xmlns="http://www.w3.org/2005/Atom">
...
  <title>orangain flavor</title>
...
</feed>

当ブログのAtomのソースもこのような形になっています。

参考: XML名前空間の簡単な説明

問題: 名前空間を含むXMLから意図した要素を取得できない

さて、このようなXML文書のtitle要素を取得しようとして、以下のように書いても要素が取得できません。

from urllib.request import urlopen
from xml.etree import ElementTree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(xml)

title = root.find('./title')  # title要素を取得するつもりがNoneとなる
print(title)  # None

以降では、この問題への対応方法を見ていきます。(RSSAtomなどのフィードに限って言えば、feedparserのようなライブラリを使えば生のXMLを意識する必要はありませんが、ここでは名前空間を持つXMLの一例としてAtomを取り上げます。)

対応方法

基本的な対応方針としては以下の2つを行います。

  1. XPath名前空間の接頭辞を含める
  2. find()などのメソッドに{名前空間の接頭辞: URI}形式のdict(本記事では名前空間マッピングと呼ぶ)を渡す

注意点は、デフォルト名前空間であっても必ず適当な接頭辞をつけなくてはいけないということです。「名前空間なし」と「デフォルト名前空間」は完全に別のものと認識されます。

ElementTree(標準ライブラリ)の場合

ElementTreeのfind()findall()では以下のようになります。

from urllib.request import urlopen
from xml.etree import ElementTree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(xml)

title = root.find('./atom:title', {'atom': 'http://www.w3.org/2005/Atom'})  # 第2引数に名前空間のマッピングを指定
print(title)  # <Element '{http://www.w3.org/2005/Atom}title' at 0x108e545e8>

ちなみに以下のようにXPath内に直接名前空間URIを書くこともできます。これはElementTreeのAPIで使用できる特殊な書式であり、有効なXPathではないため、後述するlxmlのxpath()では使用できません。(そもそもElementTreeではXPathのサブセットのみ使用できます。)

title = root.find('./{http://www.w3.org/2005/Atom}title')  # 名前空間のURIをXPathに含める

lxml(サードパーティライブラリ)の場合

lxml 3.8.0で検証しました。

lxmlのxpath()では以下のようになります。なおnamespacesはキーワード引数でしか指定できません。

from urllib.request import urlopen
from lxml import etree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = etree.fromstring(xml)

title = root.xpath('./atom:title', namespaces={'atom': 'http://www.w3.org/2005/Atom'})[0]
print(title)  # <Element {http://www.w3.org/2005/Atom}title at 0x108ab8e08>

(lxmlにもElementTree互換のfind()などがあり、ElementTreeの場合と同様の書き方ができます。が、lxmlで敢えてXPathのサブセットしか使えないこれらのAPIを使うメリットは少ないでしょう。詳しくはあとで触れます。)

XPathにデフォルト名前空間を指定したい

さて、名前空間を指定すれば取得できるのはわかりましたが、XPath名前空間を含めるのは正直面倒です。XMLではデフォルト名前空間によって簡潔に書けるようになっていますが、XPathでも同様にできないのでしょうか。

結論から言えばXPath 1.0ではできません。XPath 1.0の仕様として、名前空間を指定しない要素は常に「名前空間なし」の要素を意味し、デフォルト名前空間のようなものを指定することはできません。lxmlのFAQにもこの旨が書かれています。

XPath 2.0以降ではデフォルト名前空間を指定できるようになっているようですが、XPath 2.0以降は1.0から大きく変わってXQueryという仕様のサブセットになっています。世の中の実装も多くはXPath 1.0のままであり、lxmlで使われているlibxml2でもXPath 2.0をサポートする予定はないそうです。

参考: objective c - Does libxml2 support XPath 2.0 or not? - Stack Overflow

諦めてXPath名前空間を含めましょう、ということなのですが、微妙な感じの対処法を2つ紹介しておきます。

実はlxmlのfind()やfindall()ではデフォルト名前空間を指定できる

実はlxmlのfind()findall()はElementTreeのそれから拡張されており、デフォルト名前空間を指定できます。名前空間マッピングでキーをNoneにすると、それがデフォルト名前空間として使用されます。

title = root.find('./title', {None: 'http://www.w3.org/2005/Atom'})

さらに、lxmlではroot.nsmapでドキュメントの名前空間マッピングを取得できるため、以下のように書けます。

title = root.find('./title', root.nsmap)

また、lxmlでは名前空間URIワイルドカードを指定できるので、以下のようにも書けます。

title = root.find('./{*}title')

これらの書き方だと名前空間URIを意識しなくてよいので嬉しいですが、ElementTreeからの拡張についてドキュメント化されていないこと、lxmlでfind()を使うメリットが少ないことから、微妙な感じです。

よく使うであろうxpath()では、名前空間マッピングのキーをNoneにするとエラーになるので注意が必要です。

名前空間の宣言を消してしまう(雑)

非常に雑ですが、XMLの文字列から名前空間の宣言を消してしまうという方法もあります。以下のようにデフォルト名前空間の宣言を消せば、名前空間を気にせずに要素を取得できるというわけです。

from urllib.request import urlopen
from xml.etree import ElementTree
import re

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(re.sub(rb'xmlns=".*?"', b'', xml, count=1))

title = root.find('./title')
print(title)  # <Element 'title' at 0x108308598>

書き捨てのスクリプトであれば、これで十分な場合もあるでしょう。誤って意図しない箇所を置換してしまう可能性もあるので、自己責任でどうぞ。

デフォルト名前空間だけでなく、他の名前空間もある場合は、すべての名前空間を消す必要があります。正規表現では厳しくなってくるので、一度パースしてから書き換えるのが無難です。詳しくは以下のページを参考にしてください。

参考: Python ElementTree module: How to ignore the namespace of XML files to locate matching element when using the method “find”, “findall” - Stack Overflow

ちなみにこのページにも「XMLの文字列を正規表現で書き換えるなんてとんでもない!」というコメントがあります。

まとめ

名前空間を含むXMLに対してXPathを使用する際は、面倒ですが名前空間を1つ1つ指定しましょう。

Pythonクローリング&スクレイピングでElementTreeを扱った箇所では、シンプルさを優先して名前空間のついたAtomの代わりに名前空間のないRSS 2.0からデータを取得しました。よくあるハマりどころなので、名前空間のついたXMLを扱う方法もどこかに書いておけばよかったなと思い、記事にしました。

scraping-book.com

参考

「プログラミングHaskell」を読んだ

ちょっと前の記事で宣言したように「プログラミングHaskell」を読んだ。

A5判で本文は185ページと読みやすい分量なのに、小さな関数を1つずつ作りながらしっかりと理解できて良かった。これもHaskellの記述の簡潔さのおかげと言えるだろう。若干説明が足りないところには、すかさず訳注が挿入されており、素晴らしかった。

プログラミングHaskell

プログラミングHaskell

それぞれの章の感想

一通り読んで練習問題もだいたいやった。それぞれの章について思ったところを残しておく。

1章〜6章

これらの章はPythonLispの経験があったおかげで、あまり引っかかるところはなかった。1章に出てくるHaskellでのクイックソートのエレガントな実装によって引き込まれた。

3章での「1つ以上のクラス制約(例: Num a)を持つ型を多重定義型と呼ぶ」という定義は未だにしっくりこない。

4章で出てくるパターンマッチは便利。

7章 高階関数

畳み込みを行うfoldrfoldlは苦手だったけど、練習問題などで使ったことで少し慣れてきた。

関数合成は便利。数学で出てきた時はf(g(x))と連続して適用することに比べたメリットがよくわからなかったが、Unixのパイプみたいに関数を順に適用していく書き方ができて便利さを実感した。

以下は86ページに掲載されているencode関数。文字列に含まれる文字を1文字ずつ数値にして、2進数に変換して、8bitになるよう揃えた後に結合する。

encode :: String -> [Bit]
encode = concat . map (make8 . int2bin . ord)

関数合成によって括弧が減るのも重要。

8章 関数型パーサー

本章のプログラムは動かないと書かれていてちょっと辛かった。

結果的には、原著者のサポートページからコードをダウンロードし、Parsing.hsからParser*1, parse, itemの定義だけ抜き出してきて、failure(+++)Pで囲う形に置き換えたら動くようになった。

failure :: Parser a
failure = P (\inp -> [])

(+++) :: Parser a -> Parser a -> Parser a
p +++ q = P (\inp -> case parse p inp of
                      [] -> parse q inp
                      [(v,out)] -> [(v,out)])

9章 対話プログラム

パーサーが「残りの文字列」という状態を変化させながらパースしていくのと同様に、対話プログラムはIOによって表示や入力などの世界の状態を変化させながら動作する。この意味でパーサーと対話プログラムには共通項があり、10章のモナドの話につながっていくという流れ。

エスケープシーケンスを出力することでコンソールの表示を制御するというのは、これまでほとんどやったことなかったので新鮮だった(積極的に使いたいものではない)。

10章 型とクラスの定義

新しい型を宣言するdataはシンプルに書ける割に強力で便利。

最終的にモナドが出てきた。Haskellと言えばモナドというイメージあるけど、あっさりとした説明だった。

今のところの雑な認識としては、モナドは副作用のあるアクションをdo記法で繋げて手続きっぽく書けるようにするためのクラス。モナドによって何かスゴイことができるようになるわけではなく、(手続き型言語では普通に行われている)副作用を扱うことが純粋関数型のHaskellでもできるようになるのがスゴイという理解。

11章 切符番号遊び

ずっとインタラクティブシェル(ghci)で動かしてきたが、総当りで解くにはちょっと性能が足りなかった。ghcコンパイルする方法をググって実行ファイルを作った。

12章 遅延評価

遅延評価ってなんだか難しいけどいい感じにやってくれてるという雰囲気で使っていたが、式を簡約する時に外側から簡約していき、複数回評価しなくても良いよう同じ式にはポインタを張っておくことで実現できることを学んだ。ちなみに遅延評価のない普通の言語は内側から簡約していく。簡約の順番が得られる結果に影響しないのは、副作用のない純粋関数のみで構成されているおかげ。

無限リストを使ったエラトステネスのふるいの実装も美しかった。

13章 プログラムの論証

Haskellの関数は=で定義されているので、数式と同様に証明できるというのは面白かった。人間が手で証明するのは面倒なので、自動化したいというモチベーションもわかる。

全体的な感想と今後

関数型言語をちゃんと学ぶのは初めてで、いろいろと新しい気付きがあった。Pythonなどの関数型言語の影響を受けた言語をこれまで使ってきたことで、さほど苦しまずに学べたのはラッキーだった。

カリー化やモナドなどのキーワードを聞いても何が嬉しいのかわかっていなかったが、Haskellの世界を構成する上で必要なものだと実感できた。

しかし、Haskellが向く用途がまだよくわかっていない。Haskellのメリットは理解したが、他の言語で書くのと比べてどの程度良くなるのかは、実際にいくつかプログラムを書いてみないとわからないと思う。

そして実際に何か書いてみようと考えると、まだ学ぶべきことはいろいろある。そもそもmain関数の書き方とかモジュールの定義方法の説明とかなかった。例えばWebアプリケーション作るにはどうしたらいいんだろうとか、サードパーティライブラリの使い方とか。モナドについてももっと知りたい。

というわけで、次はとりあえず「関数プログラミング実践入門」を読んでみることにする。手元にあるのは改定される前の版の方だが。

*1:Parserの3つのinstanceの宣言も含む