WEDでのPythonの活用事例

WEDでのPythonの活用事例

💡
一億枚以上のレシートデータを扱う上で、Pythonがどのように貢献しているか

Pythonの技術選択

WEDはサーバーサイドの言語として、Python、Ruby、Goを採用しています。最初はモノリスなRubyのサーバーだけでしたが、次に選択した言語がPythonでした。OCRなどのデータ抽出周りに特化したマイクロサービスでの言語に採用されました。他にも弊社では、 BigQuery とのデータの同期などデータにまつわる部分で定期実行する処理も Python を採用しています。

このページでは、弊社でPythonを採用しているサービスの中から、2つ取り上げて活用事例を紹介します。

レシートからのデータ抽出マイクロサービス

ONE や Zero ではレジで印刷された紙の撮影画像から VisionAPI を利用して必要な情報を抽出しています。

いずれは自前のモデルを使用してOCRをすることなどを考えて、使用言語としてPythonを選択しました。現在はまだOCRのモデルは手をつけることができていませんが、OCRの前処理には実験的に画像処理を挟もうと試しています。このマイクロサービスをPythonで実装しているおかげで、機械学習を導入しようとする場合などもほとんどの場合、Pythonだけを触れば完結するようになっています。

レシートからデータ抽出を行う際には、

  1. リクエストを受け取る
  2. OCRを行う
  3. レシートの中の行を識別する
  4. ルールに基づいて、情報を抽出する
  5. レスポンスを返す

という順番で処理をしています。以降、処理について説明し、このサービスを使用しているアプリケーションのONEやZeroについて説明をしていきます。

バウンディングボックスから行を識別する

Vision APIでOCRを行うと、認識された文字のバウンディングボックスが単語や文章の塊の情報と共に返ってきます。ドキュメント用のOCRを含め、レシートを読みやすい形にパースしてくれることはありません。WEDでは、位置情報からレシートの行を認識し、それを分析しやすい形にする処理を行っています。

この処理によって、続く情報抽出の処理が行いやすくなります。

正規表現の変換

情報抽出はすべてルールに基づいて行っています。そのルールとは、 合計 の文字の右側に合計金額が記載されている、と言ったルールです。そういったルールを適用する際に文字の検索を正規表現を用いて行っています。

この際に問題となるのがOCRエラーです。よく 合計 は「合言十」などと誤認されることがあります。これを正規表現でカバーできるようにしています。

何が必要か??

ルールベースの情報抽出を実現する上でのOCRエラーをカバーするための正規表現を自動生成するためには入力された任意の正規表現を構文解析をする必要があります。単純に文字を置き換えるだけでは、問題になるケースがあります。

例えば、レシートの合計金額を取得したい場合を考えてみます。下の写真のレシートを見て、 合計金額現計 のどれかの文字列の右側にある数字が合計金額であると判断し、それを取得するための正規表現を考えます。

image
image
image

正規表現は色々考えられますが、 [合現]計|金額 が一つの解でしょう。ここで、 の文字が、 あるいは 王見 と読み込まれることが多いと分かった場合の対応を考えます。どちらで読み込まれても検索にマッチするように正規表現を変換すると、 (合|現|王見)計|金額 のようになります。

どう実現したか??

OCRエラーのパターンを辞書として作成し、それを参照して任意の拡張された正規表現を生成する必要があることがわかりました。その際に単純な置換では正規表現の構文に違反する場合があるのでそれを意識して変換する必要があります。

まず、正規表現を入力として、字句解析を行い、トークンに変換する作業を行います。

class TokenType(Enum):
    DOT = 0
    CARET = 1
    ...省略...
    CHARACTER = 98
    EOF = 99

@dataclass(frozen=True)
class Token:
    char: str
    token_type: TokenType

class Lexer:
    def __init__(self, regexp: str) -> None:
        self._string_list = list(regexp)

    def scan(self) -> Token:
        if not self._string_list:
            return Token('', TokenType.EOF)

        match self._string_list.pop(0):
            case '\\':
                return Token(f'\\{self._string_list.pop(0)}', TokenType.CHARACTER)
            case '.':
                return Token('.', TokenType.DOT)
            case '^':
                return Token('^', TokenType.CARET)
            ...省略...
            case char:
                return Token(char, TokenType.CHARACTER)

特殊文字の種類などをまとめた TokenType を Enum で定義し、 TokenType と実際の文字を保持するデータクラスを Token として定義します。 Lexer クラスは scan メソッドを実行されるごとに対象の文字列の字句解析を行い、 Token を返していきます。

次に、構文解析を行います。構文解析はLL(1)法を用いて行います。

class Node(metaclass=ABCMeta):
    @abstractmethod
    def to_regexp(self) -> str:
        pass

disctionary = {
    "合": "[合台令倉会食谷含舍佥㒲㕣白㒶]",
    "現": "([現现規挸珼垷覒覡㺺玥珥珇玵㻒瑁覝珟珮玾琩]|[王玉玊主五丰毛手生尘龶][見见貝目耳具县凨]))",
    "計": "([計訃訐針許訂訲计社]|[言訁][十++tTf忄士忄計訃訐許訂訲计])",
    "金": "[金釒余舎釜𠈔㑒凎佥]",
    "額": "([額额頵㼸䫈頟類]|[客容㝖各][頁页負真貢亙貞貝見夏賁質])",
}

class Character(Node):
    token: Token

    def to_regexp(self) -> str:
        try:
            return dictionary[self.token.char]
        except KeyError:
            return self.token.char

class Parser:
    _lexer: Lexer
    _parsing_token: Token

    def __init__(self, lexer: Lexer) -> None:
        self._lexer = lexer
        self._parsing_token = self._lexer.scan()

    def parse(self) -> Node:
        # ここで構文解析を行う

構文解析の説明は割愛します。

構文解析の結果として、構文木を表す Node クラスのインスタンスを受け取ります。構文木に変換することで、ようやく文字の変換をすることができます。文字ノードの持っている文字をあらかじめ作成しておいた辞書に基づいて変換し、それを正規表現として再構築することでOCRエラーを考慮した正規表現を生成することができます。

ほとんどの場合、用いる正規表現は変化することが少ないので、生成した正規表現は re モジュールを用いてコンパイルしておき、そのインスタンスをキャッシュしておくことで上記のステップを何度も行うことなく変換することができます。

class ReProcessor:
    @classmethod
    @lru_cache
    def format(cls, regexp: str, flags: int = 0) -> re.Pattern:
        regexp = cls.__preprocess(regexp)
        lexer = Lexer(regexp)
        parser = Parser(lexer)
        node = parser.parse()
        regexp = node.to_regexp()
        return re.compile(regexp, flags)

実際のコードで [合現]計|金額 を変換すると、

([合台令倉会食谷含舍佥㒲㕣白㒶]|([現现規挸珼垷覒覡㺺玥珥珇玵㻒瑁覝珟珮玾琩]|[王玉玊主五丰毛手生尘龶][見见貝目耳具县凨]))([計訃訐針許訂訲计社]|[言訁][十++tTf忄士忄計訃訐許訂訲计])|[金釒余舎釜𠈔㑒凎佥]([額额頵㼸䫈頟類]|[客容㝖各][頁页負真貢亙貞貝見夏賁質])

となります。これを見せられても何をやっているか把握するのはほぼ無理です。このような正規表現がコードの中に紛れ込まないようにするためにこの正規表現の変換器は非常に役に立っています。

ONE 買取データからの情報抽出

このデータ抽出マイクロサービスでは、レシート買取アプリONEで収集したレシートデータから情報の抽出を行っています。店舗に限らず、どんなレシートでも買取をしているので、そのフォーマットは多岐にわたります。先程の合計金額を抽出する例でも3パターンありました(実際にはもっとたくさんのパターンが存在し、それに対応しています)。日本においてレシートを印字するレジスターは規格が統一されておらず、それが分析を困難にしています。さまざまなフォーマットに対応することで、より多くのデータを分析することに役立ちます。

レシートのフォーマットが定まっていないとは言え、そのパターンには限りがあります。少しずつ対応フォーマットを増やしていくことで、着実に情報抽出の精度を向上させることができます。現在30種類ほどのパターンを考慮して1つのルールを作成しています。

Zero 売上票からの情報抽出

Zeroというアプリケーションでは、大規模商業施設のテナントの売り上げ報告管理のために、レジスターから印刷される売上票の画像から得られた情報をまとめて、商業施設の情報管理をデジタル化するサポートをしています。データ抽出マイクロサービスはZeroにおいても情報抽出機能を提供しており、Zeroではルールをデータベースで管理している点を除けば基本的な処理の仕方はレシートの分析と近いです。

売上票もレシートと同様に、行の認識を行い、ルールに基づいて情報を抽出します。読み込みたい数字を識別するための文字列を検索し、そこからの位置を指定することで、情報の抽出を行います。この抽出方法を含むデジタル化の手法は、特許6894615 に請求されています。指定するルールは、読み取りたい数値を探すために必要なキー(右の画像では「商品純売上」)と、その数値との位置関係です。

実際に情報を抽出したのちにどのように処理するかについては関心のあるところではありません。それについては別のアプリケーションにプログラムされています。

売上票での読み取りのルール。店舗の売り上げが「商品純売上」の1行下、一番右側の数値である場合、それをルールとして指定します。
売上票での読み取りのルール。店舗の売り上げが「商品純売上」の1行下、一番右側の数値である場合、それをルールとして指定します。

データ抽出関連のまとめと今後

レシートをOCRして、その結果から情報を抽出するという作業までを司っているサービスをPythonで実装しています。ルールベースで情報抽出を行い、その途中の過程を紹介しました。

現在開発途中のものもありますが、今後前処理として画像処理を入れたり、情報抽出後に後処理をしたりと抽出前後の部分をPythonを用いて開発していく予定です。

データ分析基盤構築

Pythonを採用しているもう一つの例として、データ分析基盤構築を紹介します。WEDでは、上記のようなレシート買取のデータを分析し、それをGCPのマネージドなデータベースに保存していて、そのデータベースにある情報を1日に1度BigQueryにデータを送るという作業を行なっています。WEDでのデータ分析などの活用はBigQueryで実現しています。

Airflow × Embulk

日次の実行は Airflow でDAGを管理し、内部では、 Embulk を使ってデータベースを BigQuery に移す作業をしています。テーブルのスキーマ変更にも大体の場合自動で対応できるようにしており、たまに自動で対応できないスキーマの変更の場合に手動で変更をして対応しています。

これによって、エンジニア以外のカスタマーサクセスチームなどがデータを参照しやすい環境を作れています。

Pythonの利用のまとめ

WEDでは、画像からの情報抽出の部分と分析用のデータ管理周りでPythonを使用しています!

この記事では使用の例を紹介させていただきました。