あつまるエンジニアブログ

集客プラットフォーム事業を手がける株式会社あつまるのエンジニアブログです

毎月数時間を要していたスキャンデータ整理をOCRで自動化した

f:id:todays_mitsui:20161119134329j:plain

どうも、株式会社あつまるで財務経理部を全力サポートしている三井です。


企業活動をするなかで見積書や請求書といった書類を発送するシーンは多いですよね。
私が勤める会社でもそういった書類をクライアントに郵送していますが、郵送する前の書類をスキャンしてスキャンデータを残しておく決まりになっています。

書類を作るのに必要なデータはすべて手元にあるものの、現物のスキャンデータがあれば安心なのも分かります。
書類に押したハンコを記録しておく意味もあるのかも知れません。


スキャンしたPDFの整理が負担に

しかし、毎月何百枚という書類のスキャンを取り発送するなかで、スキャンデータを整理する作業が負担になっていました。

スキャンを取る作業自体は書類の束をスキャナーに突っ込むだけなのですが、そうやって出来上がったPDFファイルはファイル名が 無機質な連番 になっています。
後で参照するときに目的のスキャンデータを探すことを考えると、一つひとつに適切なファイル名を付け直しておく必要があるわけです。

数百というファイルに適切な名前を付けるのは、単純ながら手間の掛かる作業です。
これまでは手作業でデータ整理をしていたようですが、スキャンデータのリネームだけで 毎月数時間を掛けていた ようです。
必要な作業とはいえあまり生産的とも思えませんし、今回は OCR 技術を使ってこのスキャンデータの整理を自動化してみました。

(※ OCR: Optical character recognition, 光学文字認識)


名前の付け方を確認しよう

最初にスキャンデータに付けるべき 適切なファイル名 について確認しました。

だいたいどの書類にも右上に「注文番号」と「発行した日付」が印字されています。
スキャンデータのPDFに付けるファイル名は注文番号と発行日付を単に並べたものでいいそうです。

f:id:todays_mitsui:20161119133353p:plain

(※画像は Image です)

例えば、注文番号が「123456」で発行日付が「2016年11月15日」であれば、ファイル名は「123456_20161115.pdf」といった具合ですね。

なるほど、

このファイル名のルール、自動化するにはなかなか都合がよさそうです。
一つずつ見ていきましょう。


1. 読み取る書類は全て弊社のフォーマット

書類毎にフォーマットがバラバラになってしまうと、求めている文字を読み取るだけでもかなり難しい課題になってしまいます。

しかし全てが自社フォーマットであればレイアウトも記載されている内容も揃っています。読み取る場所で悩むことはなさそうです。


2. 読み取り対処の文字は全て活字

手書き文字でもOCRで読めなくはないと思いますが、識字率はかなり低くなってしまうでしょう。
全てが活字で印字されているのは、正確に文字認識するうえでは理想的な条件です。


3. 読み取る文字は数字のみ

数字はたったの10種類しかなく、線もシンプルなのでもっとも文字認識しやすい対象だと云えます。
ひらがな・カタカナ・漢字混じりの文書をOCRで読もうとするとある程度の誤読を覚悟しなければいけませんが、対象が数字のみであることが事前に分かっていれば良い精度を出せそうです。

そんなわけで、充分な勝算があると踏んだ私はこのPDF整理自動化プロジェクトを粛々と進めることにしました。


自動化の方針

f:id:todays_mitsui:20161119125144p:plain

続いて自動化の方針を決定します。
自動化のプログラムはPythonで書くことにしました。具体的には以下のような手順です。

  1. PDFMiner で PDF ファイルから画像データの抜き出し
  2. 画像データ(生バイナリ)を PIL の Image オブジェクトに変換
  3. Tesseract で文字認識
  4. PDF ファイルを複製しつつリネーム

一つずつ解説します。


各工程の詳細

1. PDFMiner で PDF ファイルから画像データの抜き出し

今回は Tesseract という OCR ツールで画像の中の文字を読み取りますが。この Tesseract は JPEG や PNG などの形式しか受け付けてくれません。
PDF を直接読ませることができないので、事前準備として対応した形式に変換する必要がありました。


PDF をレンダリングして画像にする方法もあるようですが、下準備が煩雑で挫折...。
仕方なく今回は PDF を画像に変換するのではなく、PDF に埋め込まれた画像データを抽出する方法を採りました。

画像の抽出には PDFMiner という Python のライブラリを使います。
以下のコードを実行すると PDF に埋め込まれた全ての画像を取得することが出来ます。

from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.layout import LAParams, LTTextBox, LTTextLine, LTImage,  LTFigure
from pdfminer.converter import PDFPageAggregator


def extract_images(document):
    """PDF ドキュメントから画像形式のデータだけを抽出する"""

    # Create a PDF resource manager object that stores shared resources.
    rsrcmgr = PDFResourceManager()
    # Create a PDF device object.
    device = PDFPageAggregator(rsrcmgr, laparams=LAParams())
    # Create a PDF interpreter object.
    interpreter = PDFPageInterpreter(rsrcmgr, device)

    contents = []

    for page in PDFPage.create_pages(document):
        interpreter.process_page(page)
        layout = device.get_result()

        contents.extend(travarse(layout))

    return [to_pil_image(ltImage) for ltImage in contents]


def travarse(layout):
    """Layout オブジェクトを再帰的に走査して LTImage オブジェクトのみを list で返す"""

    images = []

    for obj in layout:
        if isinstance(obj, LTTextBox) or isinstance(obj, LTTextLine) or isinstance(obj, LTFigure):
            images.extend(travarse(obj))

        elif isinstance(obj, LTImage):
            images.append(obj)

    return images

ちなみに抽出されるデータの形式は生のバイナリ形式になります。


2. 画像データ(生バイナリ)を PIL の Image オブジェクトに変換

画像データを抽出できたのはいいものの、バイナリ形式のままでは何かと扱いにくいので Python で扱いやすい形式にします。 Python における代表的な画像処理ライブラリである Pillow の Image オブジェクト形式に変換しました。

import StringIO
from PIL import Image


def to_pil_image(ltImage):
    """Raw Binary を Image オブジェクトに変換"""

    buffer = StringIO.StringIO()
    buffer.write(ltImage.stream.get_rawdata())
    buffer.seek(0)
    return Image.open(buffer)

Image 形式に変換することで、元々埋め込まれていた画像データが JPG だったのか PNG だったのかを意識する必要がなくなります。
さらに、PIL の機能を使ってトリミングなどの補正処理もやりやすくなるので一石二鳥です。

標準的には Image オブジェクトはファイルストリームから生成することになっているので、 StringIO を使ってバイナリをもとにファイルストリームをエミュレートして Image オブジェクトを作ってあげます。


3. Tesseract で文字認識

いよいよ OCR に掛けて文字を読み取ってみましょう。

今回、OCR のツールとして Tesseract を利用しました。
Tesseract 自体は Python とは直接関係のない一般的な OCR ツールです。
160種類以上もの言語の学習済みデータが最初から付属している のが魅力で、中にはもちろん日本語用のものも含まれています。

事前に Tesseract をセットアップは済ませておく必要があります。
Tesseract を Python から呼び出すのには pyocr というラッパーモジュールを使いました。

試しに注文番号にあたる部分を読ませてみましょう。

f:id:todays_mitsui:20161119125229p:plain

はい、うまく読めているようですね。
先ほど述べたように、活字の数字のみであれば安定して文字認識できます。


続いて日付にあたる部分を読み取りましょう。

日付は「2016年11月15日」というような形式で書かれています。
最終的に正規表現で数字部分だけを抜き出すとはいえ、OCRに掛ける段階では「年」や「月」といった日本語の文字を読み取る必要があります。
Tesseract 標準の日本語用学習済みファイルを使って文字認識してみましょう。上手くいくでしょうか。

f:id:todays_mitsui:20161119125238p:plain

うーん、イマイチですね。
数字の 1] と読み違えています。全く似ていない (長音)に誤読してしまうのは学習に使った教師データに縦書きの文章が含まれているからでしょうか…。

とはいえ幸いなことにいま私が読み取ろうとしている文字は 0~9 と「年月日」の13文字だけです。それ以外の文字はありえない事が分かっているので、誤読の訂正も簡単なのです。
OCRに掛けた結果に ] が含まれれば 1 の誤読であろうと分かります。 Z が含まれれば恐らく 2 の誤読ですね。

というわけで、誤読されていそうな文字を一つひとつ補正する処理を挟みます。

REPLACE_PAIR = (
    (u']', u'1'), (u'}', u'1'), (u'ー', u'1'),
    (u'仔', u'年'), (u'El', u'日'), (u'E|', u'日'),
    (u'E', u'日'), (u'口', u'日'), (u'曰', u'日'),
    (u'Z', u'2'), (u'O', u'0'), (u'〇', u'0'),
    (u'I', u'1'), (u'l', u'1'),
)

# よくある誤読をヒューリスティックに訂正
for before, after in REPLACE_PAIR:
    txt = txt.replace(before, after)

# 空白を削除
txt = re.sub(r'\s+', '', txt)

このような工夫の結果、テスト用に用意したスキャンデータ20個では何とか正答率100%を達成できました。


4. PDF ファイルを複製しつつリネーム

注文番号と日付を読み取ることさえ出来れば、最後のリネームはとても簡単です。
shutil モジュールに含まれる copyfile 関数で複製しつつファイル名を変えてあげます。

import shutil


after = '{0[ordernum]}_{1[year]:0>4}{1[month]:0>2}{1[day]:0>2}.pdf'.format(ordernum, date)
shutil.copyfile(before, 'AFTER/'+after)

このような処理をフォルダに用意したスキャンデータの一つひとつに適用していけば、ファイル整理の自動化は達成されます。

f:id:todays_mitsui:20161119130455p:plain

いい感じです。


配布する

これにて目的は達成出来ましたが、最後にこのプログラムを自分以外の人にも配布することについて検討します。
実務で必要な人が手軽に使えるようにとかんがえると、やはり Windows の実行形式である EXE ファイルを作って配布するべきでしょうね。

Python で書いたプログラムにおいては、Pinstaller というツールを使う事で必要なライブラリなどを自動で取り込んだ EXE ファイルを吐き出してくれます。

$ pyinstaller main.py --onefile --clean

実行するコンピュータに Tesseract が別途セットアップされている必要はあるものの、EXE ファイルを配りさえすれば使ってもらえるのはとても便利です。
Pyinstler のインストールは pip コマンドひとつで済むので使い始めるのも楽でした。


まとめ

というわけで、PDFファイルから画像を抽出し、文字を読み取ってスキャンデータをリネームするところまで一通りの処理を自動化してみました。
Python は様々な分野で必要になる基本的ツールが何かしら揃っていて成熟しているので便利ですね。これはいいものです。

今回書いたコードの一式は GitHub に置いています。

github.com


私からは以上です。

一手間加えた INSERT - レコードが未登録のとき、登録済みのとき、

どうも、株式会社あつまるで元気よく SQL を書いている三井です。


DB にレコードを INSERT するとき、一手間加えて 未登録の場合に限って登録登録済みなら一部フィールドだけ上書き などしたくなりますよね。
ここ最近、そのような SQL を書くことが多かったのでメモしておきます。

ちなみに MySQL の独自構文などもバリバリ使っているので、他のベンダーの DB に適用するときは部分的な書き換えが必要かもしれません。


レコードが存在していなかったら新規登録、存在していれば上書き

INSERT INTO `posts`(
    `id`
    ,`title`
    ,`body`
    ,`created_at`
    ,`updated_at`
)
VALUES (
    42
    ,'test title'
    ,'test body'
    ,'1970-01-01 00:00:00'
    ,'1970-01-01 00:00:00'
)
ON DUPLICATE KEY UPDATE
    `title`       = VALUES(`title`)
    ,`body`       = VALUES(`body`)
    ,`updated_at` = VALUES(`updated_at`)

UNIQUE インデックスまたは PRIMARY KEY を重複させるようなレコードを INSERT しようとしたときに、INSERT ではなく UPDATE が実行される。
その場合でも created_at は上書きされない。

参考


レコードが存在していなかったら新規登録、存在していれば何もしない

INSERT INTO `tags` (
    `id`
    ,`name`
)
SELECT
    42
    ,'Technology'
FROM dual
WHERE NOT EXISTS (
    SELECT `id` FROM `tags`
    WHERE `name` = 'Technology'
)

タグ一覧テーブルからタグ名が 'Technorogy' であるものを探して、存在しなかった場合に限り INSERT する。
8行目でテーブル名として使われている dual は実際には参照されることのないダミーのテーブル名。

参考

JavaScript のデータを CSV で保存する

どうも、株式会社あつまるでコンサルティングに必要なデータのとりまとめをしている三井です。


意外と需要のある JavaScript のデータを CSV として保存するスニペットを書き留めます。

var data = [
  ['name'  , 'age', 'gender'],
  ['Andrew', 26   , 'male'  ],
  ['Lisa'  , 21   , 'female'],
  ['Fred'  , 41   , 'male'  ],
]

このような多重配列を元にして、

f:id:todays_mitsui:20170423135440p:plain

このような CSV を保存します。

ちなみに、

var data = [
  {name: 'Andrew', age:26   , gender: 'male'  },
  {name: 'Lisa'  , age:21   , gender: 'female'},
  {name: 'Fred'  , age:41   , gender: 'male'  },
]

このような オブジェクトの配列 にも対応させました。


んで、
最初に書いておきますが、 Mac版 Excel には対応していない CSV を扱っています 。ご容赦ください。


コード

さっそくドン、

class CSV {
  constructor(data, keys = false) {
    this.ARRAY  = Symbol('ARRAY')
    this.OBJECT = Symbol('OBJECT')

    this.data = data

    if (CSV.isArray(data)) {
      if (0 == data.length) {
        this.dataType = this.ARRAY
      } else if (CSV.isObject(data[0])) {
        this.dataType = this.OBJECT
      } else if (CSV.isArray(data[0])) {
        this.dataType = this.ARRAY
      } else {
        throw Error('Error: 未対応のデータ型です')
      }
    } else {
      throw Error('Error: 未対応のデータ型です')
    }

    this.keys = keys
  }

  toString() {
    if (this.dataType === this.ARRAY) {
      return this.data.map((record) => (
        record.map((field) => (
          CSV.prepare(field)
        )).join(',')
      )).join('\n')
    } else if (this.dataType === this.OBJECT) {
      const keys = this.keys || Array.from(this.extractKeys(this.data))

      const arrayData = this.data.map((record) => (
        keys.map((key) => record[key])
      ))

      console.log([].concat([keys], arrayData))

      return [].concat([keys], arrayData).map((record) => (
        record.map((field) => (
          CSV.prepare(field)
        )).join(',')
      )).join('\n')
    }
  }

  save(filename = 'data.csv') {
    if (!filename.match(/\.csv$/i)) { filename = filename + '.csv' }

    console.info('filename:', filename)
    console.table(this.data)

    const csvStr = this.toString()

    const bom     = new Uint8Array([0xEF, 0xBB, 0xBF]);
    const blob    = new Blob([bom, csvStr], {'type': 'text/csv'});
    const url     = window.URL || window.webkitURL;
    const blobURL = url.createObjectURL(blob);

    let a      = document.createElement('a');
    a.download = decodeURI(filename);
    a.href     = blobURL;
    a.type     = 'text/csv';

    a.click();
  }

  extractKeys(data) {
    return new Set([].concat(...this.data.map((record) => Object.keys(record))))
  }

  static prepare(field) {
    return '"' + (''+field).replace(/"/g, '""') + '"'
  }

  static isObject(obj) {
    return '[object Object]' === Object.prototype.toString.call(obj)
  }

  static isArray(obj) {
    return '[object Array]' === Object.prototype.toString.call(obj)
  }
}

CSV というクラスを定義しています。
使い方はこのように、

(new CSV(data)).save('foobar.csv')

調子こいて スプレッド演算子Set などを多用しているので、比較的新しい Chrome とかでないと動かないかも知れませんね。


CSV の仕様

CSV はとてもシンプルな仕様です。
フィールド(Excel でいうところのセル)をカンマ , で区切ったものがレコードになります。
レコード同士は改行 \n で区切ります。

フィールドに ,\n値として 含まれる場合は、それがフィールドやレコードの区切り文字ではないことを示すためにフィールド全体をダブルクォート " で囲む必要があります。
さらに " で囲ったフィールドの中に " が値として含まれる場合は " 自体をエスケープしてあげる必要があります。エスケープは " を二つ重ねて "" に置換することで行います。


文字コード

CSV ファイルを保存する際の文字コードについては特に規定されていませんが、 日本語を含む CSV を Excel で開きたい 場合には少々のテクニックを要します。

採用する文字コードの選択肢はいくつかありますが、

  1. Shift_JIS
  2. BOM 付き UTF-8
  3. BOM 付き UTF-16LE

今回は 2. BOM 付き UTF-8 を採用しています。
ただし、そうやって保存した CSV は Mac 版の Excel で開くと文字化けします


日本語を含む CSV を Excel で正しく開かせるためのテクニックについては、「CSV Unicode Excel」などのフレーズで検索していただけると闇が垣間見られると思います。


元データを用意する

もうこの記事で伝えたいことの本題は終わっているんですが、データを用意する方法にも軽く触れておきます。

例えば、ここに食べログの東京都内のラーメン屋の検索結果画面がありまして、

f:id:todays_mitsui:20170423135518p:plain

インスペクタとにらめっこしまして、

f:id:todays_mitsui:20170423135538p:plain

jQuery などを駆使してこのようなコードを書きますと、

const data = $('.list-rst').map(function() {
  const $this = $(this)

  const name         = $this.find('.list-rst__rst-name a').text()
  const score        = parseFloat($this.find('.list-rst__rating-val').text())
  const reviewCount  = parseInt($this.find('.list-rst__rvw-count-num').text(), 10)
  const dinnerBudget = $this.find('.cpy-dinner-budget-val').text()
  const lunchBudget  = $this.find('.cpy-lunch-budget-val').text()
  const holiday      = $this.find('.list-rst__holiday-datatxt').text()
  const comment      = $this.find('.list-rst__pr-title').text().trim()
  const searchWord   = $this.find('.list-rst__search-word .list-rst__search-word-item').map(function() {
    return $(this).text().trim()
  }).get()

  return {
    name,
    score,
    reviewCount,
    dinnerBudget,
    lunchBudget,
    holiday,
    comment,
    searchWord,
  }
}).get()

すると、このようなデータが取れますので、

f:id:todays_mitsui:20170423135554p:plain

先ほどの CSV クラスとしてインスタンス化して保存すると、

(new CSV(data)).save('ramen.csv')

f:id:todays_mitsui:20170423135606p:plain

このようなダイアログが開いてデータを保存できるわけですね。

Excel で開くと、

f:id:todays_mitsui:20170423135620p:plain

はい、このように。


まとめ

数ヶ月後に『あー、このサイトの情報テキトーに CSV 保存してぇ』という場面に出くわすであろう自分に捧げます。


私からは以上です。

Python3 で言語処理100本ノック 2015 - 第1章

どうも、株式会社あつまるで Python 製の社内ツールなどを作っている三井です。


乾・岡崎研究室が公開している 言語処理100本ノック 2015 に取り組んで行きます。
使用する言語は Python3 です。

第1章から第10章で構成されているのでまずは第1章から。
ではスタート。


00. 文字列の逆順

文字列"stressed"の文字を逆に(末尾から先頭に向かって)並べた文字列を得よ.

print("stressed"[::-1])
# => desserts

Python のスライスを使うだけですね。


01. 「パタトクカシーー」

「パタトクカシーー」という文字列の1,3,5,7文字目を取り出して連結した文字列を得よ.

print("パタトクカシーー"[::2])
# => パトカー

これもスライスを使うだけ。
Python のスライスは高機能ですね。

ちなみに "パタトクカシーー" から "タクシー" を取り出したいときは、

print("パタトクカシーー"[1::2])
# => タクシー


02. 「パトカー」+「タクシー」=「パタトクカシーー」

「パトカー」+「タクシー」の文字を先頭から交互に連結して文字列「パタトクカシーー」を得よ.

print("".join(s1+s2 for s1, s2 in zip("パトカー", "タクシー")))
# => パタトクカシーー

zip して join する感じで。
この程度ならまだワンライナーで書いてしまいます。


03. 円周率

"Now I need a drink, alcoholic of course, after the heavy lectures involving quantum mechanics."という文を単語に分解し,各単語の(アルファベットの)文字数を先頭から出現順に並べたリストを作成せよ.

import re

sentence = "Now I need a drink, alcoholic of course, after the heavy lectures involving quantum mechanics."

print([len(word) for word in re.split(r"[\s,.]+", sentence) if "" != word])
# => [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9]

文章を単語毎に分割する部分を re.split(r"[\s,.]+", sentence) としています。
単語の区切りに 空白(\s), ,, . を選んでいますが、これは扱う言語の種類によってカスタマイズする必要がありますね。


04. 元素記号

"Hi He Lied Because Boron Could Not Oxidize Fluorine. New Nations Might Also Sign Peace Security Clause. Arthur King Can."という文を単語に分解し,1, 5, 6, 7, 8, 9, 15, 16, 19番目の単語は先頭の1文字,それ以外の単語は先頭に2文字を取り出し,取り出した文字列から単語の位置(先頭から何番目の単語か)への連想配列(辞書型もしくはマップ型)を作成せよ.

import re

specific_indexes = (1, 5, 6, 7, 8, 9, 15, 16, 19)

sentence = "Hi He Lied Because Boron Could Not Oxidize Fluorine." \
           " New Nations Might Also Sign Peace Security Clause. Arthur King Can."

print({word[:1] if index in specific_indexes else word[:2]: index for index, word in enumerate(re.split(r"[\s,.]+", sentence), start=1) if "" != word})
# =>
# {
#   'Si': 14, 'He': 2, 'Ar': 18, 'O': 8, 'K': 19, 'C': 6,
#   'N': 7, 'Li': 3, 'B': 5, 'Mi': 12, 'Cl': 17, 'S': 16,
#   'Be': 4, 'Al': 13, 'F': 9, 'Ca': 20, 'P': 15, 'H': 1,
#   'Ne': 10, 'Na': 11
# }

うわ、このコードはひどい。
本来は適切に関数を定義したり、適切に名付けた変数を使えばもっと読みやすくなるのですが、なんか出題自体が恣意的だったのでモジュール化する気力が湧きませんでした。

全体を 辞書内包表記 で処理しています。単語毎の分割は re.split(r"[\s,.]+", sentence) で。
単語の出現順を扱うために enumerate() を使って index という変数に受けています。
indexspecific_indexes = (1, 5, 6, 7, 8, 9, 15, 16, 19) に含まれていれば、対応する単語の先頭1文字だけを取り出し(word[:1])、含まれていなければ先頭2文字を取り出して(word[:2])辞書の Key にします。

辞書内包表記, 三項演算子, スライス, enumerate(), re.split() を1行に詰め込む。行儀の悪いコードのお手本みたいですね。


05. n-gram

与えられたシーケンス(文字列やリストなど)からn-gramを作る関数を作成せよ.この関数を用い,"I am an NLPer"という文から単語bi-gram,文字bi-gramを得よ.

n-gram という概念自体、日本語だけでは説明しづらいですよね。
n-gram は「文章中に現れる N 個連続した連なり」でしょうか。

"Hello" の 3(tri)-gram は {"Hel", "ell", "llo"} という集合になります。

import re


def n_gram(seq, n=2):
    seq_set = (seq[i:] for i in range(n))

    return tuple("".join(chars) for chars in zip(*seq_set))


sentence = "I am an NLPer"

char_bi_gram = n_gram(sentence)
print("char_bi_gram:", char_bi_gram)
# => char_bi_gram:
# ('I ', ' a', 'am', 'm ', ' a', 'an', 'n ', ' N', 'NL', 'LP', 'Pe', 'er')

words = re.split(r"[\s,.]", sentence)
word_bi_gram = n_gram(words)
print("word_bi_gram:", word_bi_gram)
# => word_bi_gram:
# ('Iam', 'aman', 'anNLPer')

n_gram() という関数を定義しました。

(seq[i:] for i in range(n)) は例えば "Hello" という文字列から ("Hello", "ello", "llo") と開始を1文字ずつずらした N 個組みのシーケンスを生成します。
それを zip すると (("H", "e", "l"), ("e", "l", "l"), ("l", "l", "o")) という組みが取れるので、あとは適切に join してあげる感じで。

これ、Haskell でリスト中の連続した N 個の要素を組みにして走査したいときのやり方をそのまま持ってきました。いやぁ、Haskell やってて良かった。


06. 集合

"paraparaparadise"と"paragraph"に含まれる文字bi-gramの集合を,それぞれ, XとYとして求め,XとYの和集合,積集合,差集合を求めよ.さらに,'se'というbi-gramがXおよびYに含まれるかどうかを調べよ.

def n_gram(seq, n=2):
    seq_set = (seq[i:] for i in range(n))

    return tuple("".join(chars) for chars in zip(*seq_set))


word_x = "paraparaparadise"
word_y = "paragraph"

x = set(n_gram(word_x))
y = set(n_gram(word_y))

union = x | y
print("union:", union)
# => union: {'pa', 'se', 'ad', 'is', 'ar', 'ap', 'gr', 'ag', 'ph', 'ra', 'di'}

intersection = x & y
print("intersection:", intersection)
# => intersection: {'pa', 'ra', 'ar', 'ap'}

difference_x_y = x - y
print("difference (x-y):", difference_x_y)
# => difference (x-y): {'di', 'is', 'se', 'ad'}

difference_y_x = y - x
print("difference (y-x):", difference_y_x)
# => difference (y-x): {'ph', 'ag', 'gr'}

print("'se' in X ?:", "se" in x)
# => 'se' in X ?: True

print("'se' in Y ?:", "se" in y)
# => 'se' in Y ?: False

さきほどの n_gram() を流用します。

Python には Set という集合を扱うためのデータ型があって、一通りの集合演算がメソッドと演算子で用意されているので便利です。


07. テンプレートによる文生成

引数x, y, zを受け取り「x時のyはz」という文字列を返す関数を実装せよ.さらに,x=12, y="気温", z=22.4として,実行結果を確認せよ.

def template(x, y, z):
    return u"{x}時の{y}は{z}".format(x=x, y=y, z=z)

print(template(x=12, y="気温", z=22.4))
# => 12時の気温は22.4

文字列の .format() メソッドを使っとけというだけの課題ですね。


08. 暗号文

与えられた文字列の各文字を,以下の仕様で変換する関数cipherを実装せよ.

  • 英小文字ならば(219 - 文字コード)の文字に置換
  • その他の文字はそのまま出力

この関数を用い,英語のメッセージを暗号化・復号化せよ.

import re


def cipher(plaintext):
    return re.sub(r"[a-z]", lambda m: chr(219 - ord(m.group(0))), plaintext)


plaintext = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor" \
            " incididunt ut labore et dolore magna aliqua."
print("Plaintext:", plaintext)
# => Plaintext:
# Lorem ipsum dolor sit amet, consectetur adipiscing elit,
# sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

encrypt = cipher(plaintext)
print("Encryption:", encrypt)
# => Encryption:
# Llivn rkhfn wloli hrg znvg, xlmhvxgvgfi zwrkrhxrmt vorg,
# hvw wl vrfhnlw gvnkli rmxrwrwfmg fg ozyliv vg wloliv nztmz zorjfz.

decrypt = cipher(encrypt)
print("Decryption:", decrypt)
# => Decryption:
# Lorem ipsum dolor sit amet, consectetur adipiscing elit,
# sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

最初、『まず文章を文字毎に分解して...』とか考えていたんですが、 re.sub を使って条件に合う文字だけ置換してあげれば一撃でした。


09. Typoglycemia

スペースで区切られた単語列に対して,各単語の先頭と末尾の文字は残し,それ以外の文字の順序をランダムに並び替えるプログラムを作成せよ. ただし,長さが4以下の単語は並び替えないこととする. 適当な英語の文(例えば"I couldn't believe that I could actually understand what I was reading : the phenomenal power of the human mind .")を与え,その実行結果を確認せよ.

Typoglycemia って何? という方はコチラを参照、

ただの都市伝説かと思いきや奥深いんですよ。

import random


def stir(word):
    if 5 > len(word):
        return word

    head = word[0]
    last = word[-1]
    body = word[1:-1]

    return head + "".join(random.sample(body ,len(body))) + last

def genTypoglycemia(sentence):
    return " ".join(map(stir, sentence.split(" ")))


plaintext = "I couldn't believe that I could actually understand what I was reading : the phenomenal power of the human mind ."
print("Plaintext:", plaintext)
# => Plaintext:
# I couldn't believe that I could actually understand what I was reading :
# the phenomenal power of the human mind .

typoglycemia = genTypoglycemia(plaintext)
print("Typoglycemia:", typoglycemia)
# => Typoglycemia:
# I clon'udt bleivee that I cloud aatlulcy unredantsd what I was raiedng :
# the penonehmal pewor of the hmaun mind .

意外と 文字の順序をランダムに並び替える てところで突っかかったんでコチラを参考にしました!


所感

第1章はここまで。
正直、第1章は仕事終わりにビール飲みながら暇つぶしで解いていたんですが、後半はそういうわけにもいかなくなるでしょうね。

第2章以降は解き終わり次第随時上げていきます。


私からは以上です。


その他の章の回答はこちらから