🧾

確定申告の領収書処理をローカルVLM&ローカルLLMでやっつけてみる

2025/02/09に公開

はじめに

えー…毎度おなじみ確定申告の時期がやってまいりました。

私もこのタイミングになって、確定申告の準備を始めたのですが、今年はちょっといささか事情が異なっておりまして。

医療費控除と自由診療

実は昨年から歯の治療に結構お金をかけていて、医療費控除を申請しなければならなくなりそうだったのです。

で、e-taxで確定申告を行う場合、普通の(保険診療上の)医療費であれば、最近はマイナポータル連携によって、自動的に医療費を入力できるのでまぁいいんですが…。

https://www.nta.go.jp/taxes/shiraberu/shinkoku/tokushu/keisubetsu/iryou-koujo.htm

今回、私は自由診療で歯の治療を行っておりまして。自由診療だと保険診療とは異なって、マイナポータルで連携されないので、自分で入力する必要があります。

ここで領収書が問題になってきます。

自由診療分で医療費控除を受けるためには、「医療費集計フォーム」というExcelファイルに領収書の内容を転記して提出しつつ、領収書を5年間保管することで申告を行うことができるんですが…。

https://www.nta.go.jp/taxes/shiraberu/shinkoku/tokushu/keisubetsu/iryou-shuukei.htm

…ひょっとして、この領収書の転記って、LLMで省力化出来ないか? と思ったので、好奇心ドリブンで試してみることにしました。

入力と出力

こういう紙束が、

image

こうなります。

image

環境

ハードウェア

環境名 環境 備考
CPU Intel Core i9 12900 CPUはそれほど重要ではなく。
Memory DDR5 32GB メモリもそれほど重要ではなく。
GPU NVidia GeForce RTX 4090 VRAM 24GBが極端に重要。

ソフトウェア

環境名 環境 備考
OS Ubuntu 22.04 まぁWindowsでも同じことはできると思います。
コード実装言語 python 単純に楽だったので。
VLMランタイム HuggingFace Transformers HuggingFaceのサンプルコードをパクり流用しやすかったので。
VLM Qwen/Qwen2.5-VL-7B-Instruct Alibabaが開発したQwen 2.5ベースのVLM(非量子化モデル)。
LLMランタイム ollama ggufモデルを動かす定番ということで。llama.cppとかでも良いと思います。
LLM gemma2-27b Googleが開発したLLM。対日本語ではまぁまぁ強力ですが、最近はPhi-4等でも良いかも…?

注意

ここで示されているコードは実験用コードで、大してリファクタリングもしていないので、あらゆる環境で正しく動くかは保証しかねます。

税務処理の正確性も含めて、本コードや本手順を利用する際は、全て利用者ご自身の責任でご利用ください。

手順

では順を追って示していきます。

1. 領収書をスキャナで取り込んでpngファイル化する。
2. (VLMで処理可能なよう、解像度を調整する)
3. VLMに画像を入れ、すべての文字データを出力させる。
4. LLMに文字データを入れ、医療機関名、支払金額、日付を出力させる。
5. 領収書と照合して人間が誤りを訂正する。
6. 訂正した内容を『医療費集計フォーム』に転記(コピペ)する。

1. 領収書をスキャナで取り込んでpngファイル化する。

まずはデジタル化しないと話にならないので、スキャナなり何なりで画像として取り込みます。

私は手持ちのドキュメントスキャナを使いましたが、領収書だけを正しい角度で正確に撮影すれば、おそらくスマホ画像とかでも大丈夫です。

https://amzn.to/3QaRNZq

例えばこういう画像になります。

image

これとかは手書き文字なので、手強そうです。

image

2. (VLMで処理可能なよう、解像度を調整する)

ここから先、結構強力なVLMを使うのですが、入力画像の解像度が大きいと、VLMモデルがGPUのVRAMからあふれる(OOM) ことがあるので、解像度を調整します。

私は領収証の画像一式を./pngフォルダに取り込んでおいて、mogrifyコマンドで一括して変更しました。

mogrify -resize 50% png/**/*.png

私の環境だと、大きいものでもだいたい1000x700 Pixelぐらいに調整して入れています。

3. VLMに画像を入れ、すべての文字データを出力させる。

ここが1つ目の肝になります。

使用するVLMはこちら。

https://huggingface.co/Qwen/Qwen2.5-VL-7B-Instruct

Alibabaが開発したQwen 2.5ベースのVLMです[1]。GPU性能と精度を考慮して、今回は7Bクラスの非量子化モデルを使います。

で、こんなコードを書いて実行します。

環境は参照されているライブラリを見ながら適当に作りましょう。私はvenv上のpipでちまちま入れて作りました。

text_extractor.py
from PIL import Image
import glob
import os
import argparse
import json

from transformers import Qwen2_5_VLForConditionalGeneration, AutoTokenizer, AutoProcessor

def extract_txt_from_image(model,processor,image_path,prompt):

    image = Image.open(image_path)
    text_prompt = processor.apply_chat_template(prompt, add_generation_prompt=True)

    inputs = processor(
        text=[text_prompt],
        images=[image],
        padding=True,
        return_tensors="pt"
    )
    inputs = inputs.to("cuda")

    output_ids = model.generate(**inputs, max_new_tokens=2048)
    generated_ids = [
        output_ids[len(input_ids) :]
        for input_ids, output_ids in zip(inputs.input_ids, output_ids)
    ]
    output_text = processor.batch_decode(
        generated_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True
    )

    return output_text[0]

def main():

    png_folder = "png/"
    txt_folder = "txt/"

    parser = argparse.ArgumentParser(description="List all PNG files in a given directory and its subdirectories.")
    parser.add_argument('directory', type=str, help="The directory to search for PNG files.")
    parser.add_argument('--prompt_json',type=str,help="json file that store a prompt for a vlm model",default="./extract_text.json")
    args = parser.parse_args()

    png_folder = args.directory + png_folder
    txt_folder = args.directory + txt_folder

    with open(args.prompt_json,"r",encoding="utf-8") as json_file:
        prompt = json.load(json_file)

    png_file_paths = glob.glob(os.path.join(png_folder, '**', "*.png"), recursive=True)
   
    model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
        "Qwen/Qwen2.5-VL-7B-Instruct",
        torch_dtype="auto",
        device_map="auto"
    )
    processor = AutoProcessor.from_pretrained(
        "Qwen/Qwen2.5-VL-7B-Instruct",
    )

    for path in png_file_paths:
        rel_path = os.path.relpath(path,start=png_folder)

        txt_path = txt_folder + os.path.splitext(rel_path)[0] + ".txt"  # .png → .txt
        txt_dir = os.path.dirname(txt_path)

        if txt_dir and not os.path.exists(txt_dir):
            os.makedirs(txt_dir, exist_ok=True)
        
        if not os.path.exists(txt_path):
            print("image:" + path + " to txt:" + txt_path)
            extracted_text = extract_txt_from_image(model,processor,path,prompt)
            print(extracted_text)
            with open(txt_path, 'w', encoding='utf-8') as f:
                f.write(extracted_text)
        else:
            print(txt_path + " is already exists. skipped.")
 
if __name__ == '__main__':
    main()

この例ではHuggingFace Transformersを直接読んで実行していますが、ご希望の方はVRAMの許す限り、VLLM等お好きなLLMランタイムをお使いください。

で、コードよりも重要なプロンプトはこんな感じになっています。

extract_text.json
[
    {
        "role": "user",
        "content": [
            {
                "type": "image"
            },
            {"type": "text", "text": "記載されている日本語の文字、数字、文章をすべて正確に抽出してください。\n**抽出した文章以外は出力しないでください。**\n**日本語の文字コードで出力してください。**"}
        ]
    }
]

後ほど再び出てきますが、ローカルLLMにおいて手を焼くのがプロンプトへの追従性です。

クラウドLLMと違ってローカルLLMは、特に日本語指示に対する追従性に難があるため、プロンプトの厳密化や強調を駆使する必要があります[2]

カレントフォルダにpngフォルダを作って、そこにすべての領収書画像を放り込んで、このpythonコードを動かしてやります。

$ python3 text_extractor.py ./

実行すると、txtフォルダに領収書から取り出されたテキストデータが出てきます。

image

この辺とかは金額や日付の解析に失敗しています。やはり手書きの領収書だと精度が落ちるようです。これは後で手で訂正する必要がありそうです。

image

4.LLMに文字データを入れ、医療機関名、支払金額、日付を出力させる。

で、ここで得られたテキストファイルの内容を、再度別のLLMに入力し、テキストから必要な情報(金額、医療機関名、日付)だけを抽出してもらいます。

Ollamaを立ち上げて、以下のようなコードを実行します。

txt_to_csv.py
import glob;
import os;
import io
import csv

from langchain.schema import SystemMessage
from langchain_community.chat_models import ChatOllama
from langchain.prompts import ChatPromptTemplate
from langchain.prompts.chat import (
    ChatPromptTemplate,
)
import csver_const
import argparse
import csver_const

system_prompt = SystemMessage(content=csver_const.system_prompt_text)
text_to_csv_template = ChatPromptTemplate.from_template(csver_const.text_to_csv_template_text)

chat_ollama_llm_client = ChatOllama(
    model=csver_const.OLLAMA_CLASSIFIED_MODEL,
    base_url=csver_const.OLLAMA_BASE_URL,
    temperature=0)

def txt_to_csv(target_text:str):
    user_prompt = text_to_csv_template.format_prompt(
        target_text=target_text
    ).to_messages()
    prompts = []
    prompts.append(system_prompt)

    prompts.extend(user_prompt)

    response = chat_ollama_llm_client.invoke(prompts)
    return response.content

def main():

    txt_folder = "txt/"
    csv_folder = "csv/"

    parser = argparse.ArgumentParser(description="List all txt files in a given directory and its subdirectories.")
    parser.add_argument('directory', type=str, help="The directory to search for txt files.")
    args = parser.parse_args()

    txt_folder = args.directory + txt_folder
    csv_folder = args.directory + csv_folder

    output_csv_file_path = csv_folder + "/" + "output.csv"

    if csv_folder and not os.path.exists(csv_folder):
        os.makedirs(csv_folder, exist_ok=True)

    txt_file_paths = glob.glob(os.path.join(txt_folder, '**', "*.txt"), recursive=True)
    with open(output_csv_file_path,'a', encoding='utf-8') as output_csv_file:
        output_csv_file.write("医療を受けた人,病院・薬局などの名称,診療・治療,医薬品購入,介護保険サービス,その他の医療費,支払った医療費の金額,左のうち、補填される金額,支払年月日\n") 

        for txt_file in txt_file_paths:

            with open(txt_file, 'r', encoding='utf-8') as f:
                question_text = f.read()
                csv_line = txt_to_csv(question_text).rstrip('\n')      
                print(csv_line)
                if csv_line.strip() != "":
                    csv_io = io.StringIO(csv_line)
                    reader = csv.reader(csv_io)
                    for row in reader:
                        if len(row) == 4:  # 4カラムあるか確認
                            person, clinic_name, amount, date = row[:4]
                            output_csv_file.write(f"{person},{clinic_name},,,,,{amount},,{date}\n")

if __name__ == '__main__':
    main()

唐突にLangChainを使ってますが、これは流用元の都合でたまたまそうなっているだけで、この程度のプロンプトであればopenai apiを直接使っても構いません。

プロンプトを含む定数はこんな感じです。

csver_const.py
OLLAMA_BASE_URL = "http://localhost:11434/"
OLLAMA_CLASSIFIED_MODEL = "gemma-2-27b-it:latest"

system_prompt_text = '''
You are an accurate data structure converter. It interprets text as supported and extracts it in machine-readable Japanese character codes; it does not perform any markdown modification and outputs only the specified data structures.
'''

text_to_csv_template_text="""

以下は領収書のテキストです。

---領収書のテキスト---

{target_text}

---領収書のテキストここまで---

あなたは上記領収書のテキストから、以下のフォーマットでCSVを出力する変換器です。
以下フォーマットに厳密に従い、csvデータを出力してください。
**markdown修飾は禁止です。データ業のみを出力してください。**
**以下フォーマットからの逸脱は禁止されています。カンマの位置を含め、正確にこのフォーマットで出力してください。**
**以下フォーマット以外の情報出力は一切禁止されています。**
**支払日はYYYY.MM.DD形式で出力してください。なお、令和5年は2023年、令和6年は2024年です。**
**医療を受けた人の名前は"dimeiza"です。

(医療を受けた人),医療機関名,支払金額,支払日

""" 

ここではローカルLLMモデルとしてgemma-2-27b-itを使っています。日本語に対応した強めのLLMモデルが望ましいです。

実行すると、LLMの出力を標準出力に出しつつ、

image

./csvフォルダに『医療費集計フォーム』と同じ体裁のcsvが吐き出されます。

image

5. 領収書と照合して人間が誤りを訂正する。

ここからは人間のターンです。

先ほど作成したcsvであるoutput.csvを、ExcelやLibreOffice等のスプレッドシートツールで開きます。

image

で、見ての通り、いくつかおかしなところがあるので、各行と手元の領収証を人間がチェックして修正していきます。

このcsvの場合、

  • 支払い年月日が2024年内の適切な日付でないものがある。
    • 2023、2025、200x.06.xなど。
      • 2023,200xは認識ミスなので、領収書の正しい日付に訂正。
      • ちなみに2025の奴は認識ミスではなく、実際に2025年の領収書が混入していた。
  • 金額がおかしいものがある。
    • (誤)1221000→(正)122100。
    • (誤)7100→(正)1100。
  • 医療機関名が誤っている。
    • 最後の行だけ医療機関名の『の』が一つ多い(1つ上の行と全く同じ医療機関なのに)。

こんな感じで誤りが混入しているので、領収書と対比しながら直していきます。

6. 訂正した内容を『医療費集計フォーム』に転記(コピペ)する。

すべての訂正が終わったら、

image

この箇所を選択して『医療費集計フォーム』に貼り付けます。

最後に『医療費の区分』を設定すると、最初に示した状態になって入力完了[3]です。

image

というわけで、

  • 全項目の手入力を経ず、電子化とVLM、LLMによる自動化を駆使することで、
  • 確定申告でも面倒とされる領収書類の整理と帳票作成を、

比較的楽にやっつけることが出来ました。

意識したポイント

一連の手順を見ていて、あれ? 何で? と思った方もいるかもしれないので、この事例であえて意識したポイントを書いておきます。

  • 複数のデータ処理要求がある場合、1回のプロンプト、1つのLLMだけで全てを完結させない。
  • 各処理終了のタイミングで意識的にファイル保存する。
  • LLM、VLMによる自動化だけで手順を組み立てない。

ここに書いてないことはおそらく気まぐれで、たまたまそうしたってだけの可能性が高いです。

複数の依頼がある場合、1回のプロンプト、1つのLLMだけで全てを完結させない。

ローカルLLMの場合特に顕著ですが、LLMは一度にたくさんの命令を放り込むと、すべての命令を正確に実行できなくなる可能性が高まる傾向があります。

  • その気になればVLM(Qwen2.5-VL)に対するプロンプトで、文字列抽出だけではなくCSV化まで指示できるのでは?

とお考えの方もいらっしゃると思うんですが、あえてそうしなかったのはVLMに対して『文字列を正確に取り出す』タスクを完璧に実行してほしかったからなのです。

そこで、

  • VLMによる記載内容のテキスト化
  • LLMによるテキストからの情報抜き出し

この2つを別々に行うことで、各処理の確実性を期することにしました。

各処理終了のタイミングで意識的にファイル保存する。

LLM横断のアプリを組むと、LLMの出力を別のLLMに投げる、的なことが増えます。

各段階で得られる結果は非決定的なものなので、後段のLLM出力が不適切だった場合、なぜそうなったのかを前段のLLM出力から検証したくなります。

このとき、前段のLLM出力結果を保存していないと、検証のしようがないんですね。

また、各処理のLLM出力結果を保存していれば、同じ結果を入力として複数の後段LLM処理を並行して設計したり、前段の重いLLM処理の実行を省略したりと、色々と小回りが利くようになって良いです。

LLM、VLMによる自動化だけで手順を組み立てない。

これはローカルLLMに慣れている人であれば常識かもしれませんが、まだまだLLMは間違いの多い代物です。

本事例で先程出てきたVLMの出力はまさにそうですが、最初から最後まで全ての処理を任せられる品質には残念ながら到達していません。

クラウドLLMでさえそうなので、ローカルLLMで処理の完璧が期されるようになるのは、まだ先のことでしょう。

よって、ローカルLLMを使う我々としては、必ず途中で人間が介入して検証を行うことが必須になってきます。

まして今回は税務処理という、間違えるとペナルティが人間に行くような代物なので、LLMを使う業務フローには人間による検証を介在させるようにしています。

おわりに

というわけで、普段から興味を持って使っているLLM、VLMに、割と実用的な用途が出てきたので、ワクワクしながら使ってみた次第です。

DeepSeekショックが発生して初めて気づかされた方々も多いと思いますが、今年以降、おそらくローカルLLM(プライベートLLM)は、その本源的な需要(プライベートな処理のために所有したい!)と計算力の許容性(手元のGPUでも動かせる!)が相まって、本格的に浸透していくことになるでしょう。

ローカルLLMを使ってできることはますます増えていくはずで、なかなか面白い時代になったなと思っています。

この先もゆっくりまったり、LLMと組んで楽しんでいこうと思っています。

P.S.

最後に一つ。

歯は大事にしましょう。特に20代、30代の若い皆様。

歯の疾患が進行すると、保険治療では取り返しがつかず、抜歯回避のために高額な自由診療を使わざるを得なくなります。

今回の私の治療は20代〜30代前半に、多忙でメンテ出来ずに進行した、親知らずの虫歯が悪さをした結果、隣の歯が巻き添えを食って抜歯直前までいった事例だったりします。

こんな感じで若い時分に歯のメンテを怠ると、後で高い授業料を払うことになるので、日々の歯磨き、フロス、定期検診、間食の防止、飲み物への注意など、若い頃から意識的に歯を守っていきましょう。

また、若手に限った話ではなく、そもそも日本人はデンタルリテラシーが低めなので、意識的に勉強していったほうが良いと思っています。

脚注
  1. 昨年の段階からQwen 2-VLを触っていて、かなりの性能で驚いていましたが、さらに高速化し、日本語も安定して出力できるようになっています。 ↩︎

  2. プロンプトの英語化も追従性向上に効くことがあるようです。 ↩︎

  3. ちなみに『医療を受けた人』や『医療費の区分』は、CSVに変換するプロンプトの中なり、出力処理内で固定値を設定することも可能ですし、『医療を受けた人』を領収書から取り出して設定することも可能です。 ↩︎

Discussion