LoginSignup
2
6

More than 1 year has passed since last update.

手作業を減らしたい!Python から Markdown レポートを生成する ~ mdutils を使った Optuna 最適化レポートの自動生成 ~

Posted at

1. はじめに

 夜にシミュレーションを回して帰って、朝に結果を確認することが多いのですが,大量の図を1個1個エクスプローラーからダブルクリックして見るのは面倒なので、簡単に結果を確認するためのレポートを Python から出力させたいと思います。

本ページで紹介すること

  • mdutils パッケージを使用した簡単な Markdown レポート生成サンプルの紹介。
  • 上記の応用例として、optuna を使ったパラメータ最適化結果のレポートを自動生成させた例の紹介。

Python から HTMLのレポートを直接出力する手もありますが、あとから編集するのが大変なので、ここでは Markdown で出力し、Pandoc や VScode の拡張機能などで HTML や pdf に変換して使用することを想定しています。

2. 基本の Markdown レポート生成例

2.1. 開発環境

  • Windows 11
  • Python 3.8.10 (3.10.9でも動作を確認)
使用したパッケージ (requirements.txt) はこちら
requirements.txt
alembic==1.9.4
cmaes==0.9.1
colorama==0.4.6
colorlog==6.7.0
contourpy==1.0.7
cycler==0.11.0
fonttools==4.38.0
greenlet==2.0.2
importlib-metadata==6.0.0
importlib-resources==5.12.0
joblib==1.2.0
kiwisolver==1.4.4
Mako==1.2.4
MarkupSafe==2.1.2
matplotlib==3.7.0
mdutils==1.5.0
numpy==1.24.2
optuna==3.1.0
packaging==23.0
Pillow==9.4.0
pyparsing==3.0.9
python-dateutil==2.8.2
PyYAML==6.0
scikit-learn==1.2.1
scipy==1.10.1
six==1.16.0
SQLAlchemy==2.0.4
threadpoolctl==3.1.0
tqdm==4.65.0
typing-extensions==4.5.0
zipp==3.15.0

2.2. mdutils を使った基本のレポート生成

2.2.1. Markdown 生成パッケージ

 Python から Markdown を生成するパッケージは色々あるのですが、ここでは Star の数が多く、ざっと調べてみた感じ直感的に書けそうな mdutils を選定しました。

2.2.2. mdutils のサンプルコード

 まず、簡単なサンプルコードを紹介します。mdutils をインストールします。

$ pip install mdutils

 下記の図のような簡単な Markdown レポートを生成するサンプルコードは以下のようになります。

mdutils サンプル

mdutils_example.py
from mdutils.mdutils import MdUtils

def generate_report(export_path, table_header, table_list, image_path):
    mdFile = MdUtils(file_name=export_path, title='Python から Markdown を生成するサンプル')

    # はじめに
    mdFile.new_header(level=1, title='はじめに')
    mdFile.new_line("本書は Markdown の自動生成テストしたものです。"
            "mdutils を使用して作成しました。")
    
    mdFile.new_header(level=1, title='各要素の例')

    # 箇条書き
    mdFile.new_header(level=2, title='箇条書き')
    mdFile.new_list(["項目1",["項目1.1"],"項目2"])

    # 表を貼る
    mdFile.new_header(level=2, title='')
    list_of_strings = table_header
    for line in table_list:
        list_of_strings.extend(line)
    mdFile.new_table(columns=3, rows=3, text=list_of_strings, text_align='left')

    # イメージを貼る
    mdFile.new_header(level=2, title='イメージ')
    mdFile.new_line(mdFile.new_inline_image(text='写真', path=image_path))

    # リンクを貼る
    mdFile.new_header(level=2, title='リンク')
    mdFile.new_line("Qiita トップページへのリンクです。")
    mdFile.new_line(mdFile.new_inline_link(link="https://qiita.com/", text='リンク'))

    # ファイルを生成する
    mdFile.create_md_file()

if __name__ == "__main__":
    # アプリケーション実行
    table_header = ["列1", "列2", "列3"]
    table_list = [[0.1, 0.2, 0.3],["apple", "banana", "cabbage"]]
    image_path = "./mycat.png"
    
    # レポート生成
    generate_report("mdutils_export_sample.md", table_header, table_list, image_path)

コードの補足

  • mdutils の使い方は mdutilsのトップページドキュメント に例が豊富です。

  • mdutils で Markdown を作成する流れは、以下のようになります。

    1. インスタンスを作成する。

      mdFile = MdUtils(file_name="sample.md", title='Python から Markdown を生成するサンプル')
      
    2. Markdownに出力したい要素を上から順番に追加する。

      # ただのテキストを追加する場合
      mdFile.new_line("テキスト")
      # 図を貼る場合
      mdFile.new_line(mdFile.new_inline_image(text='写真', path='./sample.png'))
      
    3. ファイルにエクスポートする。

      mdFile.create_md_file()
      
  • 表の作成だけ少しややこしいので補足すると、すべての項目を1つの list にまとめてから

    list_of_strings = ['列1', '列2', '列3', 0.1, 0.2, 0.3, 'apple', 'banana', 'cabbage']
    

    以下のように列数、行数を指定して出力します。

    mdFile.new_table(columns=3, rows=3, text=list_of_strings, text_align='left')
    

2.3. 補足: Pythonの基本機能だけで頑張る

 Markdown はただのテキストなので、あまり複雑なレポートを作成しないなら、Python の基本機能だけでべた書きしてもいいと思います。

 先ほどの例を Python の基本機能だけで書き換えると以下のようになります。表の作成だけがちょっと大変ですが、それ以外の部分(図の埋め込みだけ自動化したいなどといった用途)だと、地道に書くこちらの方法もありだと思います。

basic_example.py
def generate_report(export_path, table_header, table_list, image_path):
    """
    Markdown のレポートを生成する
    """
    
    # 変更のない固定文を出力する
    text = """\
Python によるMarkdown生成サンプル
================================

# はじめに

本書は Markdown の自動生成テストしたものです。
この例では,Pythonの基本機能ですべてべた書きで記載します。

# 各要素の例

## 箇条書き

* 項目1
    * 項目1.1
* 項目2
    
## 表

"""
    # 表を貼る
    text += generate_table(table_header, table_list)

    # イメージを貼る
    text += "\n\n## イメージ\n\n"
    text += generate_imagelink(image_path, image_title="写真")

    # リンクを貼る
    text += "\n\n## リンク\n\nQiita トップページへのリンクです。\n\n"
    text += generate_link("https://qiita.com/", title="リンク")
    
    # ファイルに書き出す
    with open(export_path, 'w', encoding='utf-8') as f:
        f.write(text)

    return text
    

def generate_table(table_header, table_list):
    """
    リストから、表を貼り付けるテキストを作成する
    """

    # ヘッダー行を作る
    text = ""
    for header in table_header:
        text += f"|{header}"
    text += "\n"
    
    # 区切り文字を作る
    text += ("|:-" * len(table_header))
    text += "\n"
    
    # 中身を作る
    for line in table_list:
        for item in line:
            text += f"|{item}"
        text += "\n"
    
    return text

def generate_imagelink(image_path, image_title=""):
    """
    画像ファイルパスから、画像貼り付けテキストを作成する
    """

    return f"![{image_title}]({image_path})"

def generate_link(link, title=""):
    """
    リンク(url)から、リンク貼り付けテキストを作成する
    """

    return f"[{title}]({link})"


if __name__ == "__main__":
    # アプリケーション実行
    table_header = ["列1", "列2", "列3"]
    table_list = [[0.1, 0.2, 0.3],["apple", "banana", "cabbage"]]
    image_path = "./mycat.png"

    # レポート生成
    generate_report("basic_export_sample.md", table_header, table_list, image_path)

3. optuna 最適化レポート自動生成例

 ここからは、応用例として optuna を使ったパラメータ最適化の結果のレポートを mdutils を使って自動生成したいと思います。

例題

  • サンプルデータを与えて、以下の2次関数の近似曲線を求める(次式の最適なパラメータ k_0, k_1, k_2 を探索する)

    y = k_2 x^2 + k_1 x + k_0
    
  • 評価関数としては MSE(平均二乗誤差) を使用する。

  • optuna が提供するサンプラー BaseSampler(ベイズ最適化), RandomSampler(ランダム), CmaEsSampler(CMA-ES) ごとに以下の結果を確認する。

    • ステップごとの最適化結果
    • 最終的に得られた最適な k_0, k_1, k_2 とその MSE
    • 最適化にかかった時間

ここからは以下のパッケージを使用します。

requirements.txt
mdutils
numpy
matplotlib
scikit-learn
optuna

3.1. コードと生成されたレポート

3.1.1. コード

 長いので、先にコードと生成された Markdown レポートを紹介し、あとからコードについて補足します。

optuna_report_example.py
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import mean_squared_error
import optuna
import os
import time
import pickle
from mdutils.mdutils import MdUtils

RESULT_FOLDER = "./results"

def make_sample_data():
    """
    サンプルデータを作成する
    """
    x = np.arange(0.0, 2, 0.1)
    y=  -0.9*x**4 +2*x**3 + 0.5*x

    os.makedirs(RESULT_FOLDER, exist_ok=True)
    fig = plt.figure()
    plt.plot(x, y, 'o')
    plt.savefig(os.path.join(RESULT_FOLDER, "sample_data.svg"))
    return x, y

def fx(x, params):
    """
    使用する2次関数
    """
    y = params['k2'] * x**2 + params['k1'] * x + params['k0']
    return y

def param_tune(x_target, y_target, method="base"):
    """
    パラメータの最適化を行う。例として、2次関数の係数の最適化を行う。
    """
    x = x_target
    def objective(trial):
        # 目的関数
        k0 = trial.suggest_float('k0', -10, 10)
        k1 = trial.suggest_float('k1', -10, 10)
        k2 = trial.suggest_float('k2', -10, 10)
        y = fx(x, {'k0': k0, 'k1': k1, 'k2': k2})
        J = mean_squared_error(y_target,y)
        return J

    # 最適化手法については https://optuna.readthedocs.io/en/stable/reference/samplers/index.html
    if method=="cmaes":
        title = "CmaEsSampler"
        sampler = optuna.samplers.CmaEsSampler()
        study = optuna.create_study(direction="minimize", sampler=sampler)
    elif method=="random":
        title = "RandomSampler"
        sampler = optuna.samplers.RandomSampler()
        study = optuna.create_study(direction="minimize", sampler=sampler)
    else:
        title = "BaseSampler"
        study = optuna.create_study(direction="minimize")
    
    # optunaの経過表示を非表示にする
    optuna.logging.set_verbosity(optuna.logging.WARNING)

    start = time.time()
    # 最適化を行う。
    study.optimize(objective, n_trials=1000)
    # 経過時間を取得する
    elapsed_time = time.time() - start
    print(study.best_params)

    # 最適化の過程から、途中の最適値を取り出す。
    values = [each.value for each in study.trials]
    params = [each.params for each in study.trials]
    best_values = []
    best_params = []
    for i in range(len(values)):
        target = values[:i+1]
        best_index = np.argmin(target)
        best_values.append(target[best_index])
        best_params.append(params[best_index])
    
    # 一番最後のベストパラメータ
    best_param = study.best_params
    
    # 図を格納するフォルダを作成する
    target_dir = os.path.join(RESULT_FOLDER, method)
    os.makedirs(target_dir,exist_ok=True)

    # 最適化結果を可視化する。
    fig_path = os.path.join(target_dir, "tune_step.svg")
    plot_tune_step(title, values, best_values, best_params, fig_path=fig_path)

    # 最適化後の結果を表示する。
    fig_path = os.path.join(target_dir, "tune_result.svg")
    best_mse = plot_tuned_result(x_target, y_target, best_param, fig_path=fig_path)
    
    # 結果をpickleに書き出す
    with open(os.path.join(target_dir, 'result.pickle'), 'wb') as f:
        result = {"k0": best_param["k0"], "k1": best_param["k1"], "k2": best_param["k2"],"mse": best_mse, "elapsed_time": elapsed_time}
        pickle.dump(result, f)

    return best_param

def plot_tune_step(title, values, best_values, best_params, fig_path=None):
    """
    各最適化手法における最適化の過程を可視化する。
    """   
    # 可視化する
    fig = plt.figure(figsize=[8,6])

    # 上の図に目的関数の最適化結果を表示する
    ax1 = plt.subplot(211)
    ax1.set_title(title)
    ax1.plot(values, label="value")
    ax1.plot(best_values, label="best value")
    ax1.set_yscale('log')
    ax1.set_ylabel("Objective value")
    # 凡例は外に出す
    ax1.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0)

    # 下の図に、パラメータ結果を表示する。
    ax2 = plt.subplot(212)
    ax2.plot([param['k0'] for param in best_params], label="k0")
    ax2.plot([param['k1'] for param in best_params], label="k1")
    ax2.plot([param['k2'] for param in best_params], label="k2")
    ax2.set_ylabel("Best parameters")
    ax2.set_xlabel("Iteration step")
    ax2.legend(bbox_to_anchor=(1.05, 1), loc='upper left', borderaxespad=0)
    plt.subplots_adjust(right=0.8)

    if fig_path is not None:
        # 結果を保存する
        plt.savefig(fig_path)
    else:
        plt.show() 
    
    plt.clf()
    plt.close()

def plot_tuned_result(x_target, y_target, params, fig_path=None):
    """
    ベストなパラメータを使用したときの、元の点との比較を結果をプロットする。
    """
    y = fx(x_target, params)
    fig = plt.figure()
    plt.plot(x_target,y_target,'o')
    plt.plot(x_target,y,'r-')
    mse = mean_squared_error(y_target,y)
    plt.title(f"MSE: {mse}")

    if fig_path is not None:
        # 結果を保存する
        plt.savefig(fig_path) 
    else:
        plt.show()
    plt.clf()
    plt.close()
    return mse

def write_report(export_path):
    """
    Markdownのレポートを生成する
    """
    mdFile = MdUtils(file_name=export_path, title='optuna を使用した最適化サンプル')

    # はじめに
    mdFile.new_header(level=1, title='はじめに')
    mdFile.new_line("本書は Markdown の自動生成テストしたものです。"
            "サンプルとして optuna を使用し、2次関数の近似線を求めます。")
    mdFile.new_line()

    mdFile.new_header(level=2, title='最適化に使用するデータ')

    target_image = os.path.join(RESULT_FOLDER, "sample_data.svg")
    mdFile.new_line(mdFile.new_inline_image(text='サンプルデータ', path=target_image))
    mdFile.new_line()

    mdFile.new_header(level=2, title='最適化する2次多項式')

    mdFile.insert_code("y = k_2 x^2 + k_1 x + k_0", language='math')
    mdFile.new_line()

    mdFile.new_header(level=1, title='optuna で最適化した結果')

    def write_methods_result(title, method):
        """
        各最適化手法の結果を書くサブ関数
        """
        mdFile.new_header(level=2, title=title)
        # 図を貼る
        target_image = os.path.join(RESULT_FOLDER, method, "tune_step.svg")
        mdFile.new_line(mdFile.new_inline_image(text='最適化過程', path=target_image))
        mdFile.new_line()

        target_image = os.path.join(RESULT_FOLDER, method, "tune_result.svg")
        mdFile.new_line(mdFile.new_inline_image(text='最適化後の結果', path=target_image))

        # 結果の表を貼る
        with open(os.path.join(RESULT_FOLDER, method, 'result.pickle'), mode="rb") as f:
            # 結果が dictの中に入っているので、キーと値を取り出して縦に並べる
            result = pickle.load(f)
            list_of_strings = ["パラメータ", ""]
            for key, val in result.items():
                list_of_strings.extend([key, str(val)])
            mdFile.new_line()
            mdFile.new_table(columns=2, rows=len(result)+1, text=list_of_strings, text_align='left')

    # 各最適化手法ごとの結果を表示
    write_methods_result("BaseSampler", "base")
    write_methods_result("RandomSampler", "random")
    write_methods_result("CmaEsSampler", "cmaes")

    # ファイルを生成する
    mdFile.create_md_file()

if __name__ == "__main__":
    # サンプルの目標データを作成する
    x, y = make_sample_data()
    
    # 異なる最適化手法で最適化を実行する
    params = param_tune(x, y, method="random")
    params = param_tune(x, y, method="cmaes")
    params = param_tune(x, y, method="base")

    # レポートを生成する
    write_report("optimize_report.md")

3.1.2. 生成された Markdown

optimize_report.md
optuna を使用した最適化サンプル
===================

# はじめに
  
本書は Markdown の自動生成テストしたものです。サンプルとして optuna を使用し、2次関数の近似線を求めます。  

## 最適化に使用するデータ
  
![サンプルデータ](./results\sample_data.svg)  

## 最適化する2次多項式


```math
y = k_2 x^2 + k_1 x + k_0
```  

# optuna で最適化した結果

## BaseSampler
  
![最適化過程](./results\base\tune_step.svg)  
  
![最適化後の結果](./results\base\tune_result.svg)  

|パラメータ|値|
| :--- | :--- |
|k0|-0.3123366018336038|
|k1|1.6713739299342616|
|k2|0.18685519487057417|
|mse|0.05304465728892915|
|elapsed_time|25.70290446281433|

## RandomSampler
  
![最適化過程](./results\random\tune_step.svg)  
  
![最適化後の結果](./results\random\tune_result.svg)  

|パラメータ|値|
| :--- | :--- |
|k0|-1.0042492694679446|
|k1|2.3503184374400465|
|k2|-0.17606037160065746|
|mse|0.34791378366778514|
|elapsed_time|1.036989688873291|

## CmaEsSampler
  
![最適化過程](./results\cmaes\tune_step.svg)  
  
![最適化後の結果](./results\cmaes\tune_result.svg)  

|パラメータ|値|
| :--- | :--- |
|k0|-0.29319171439289105|
|k1|1.8609057100494433|
|k2|0.06342857426414414|
|mse|0.048847462205142786|
|elapsed_time|2.283266067504883|

3.1.3. Markdown レポートのレンダリング結果

以下生成された Markdown のレポートを Qiita に貼り付けました。 Qiita でレンダリングさせるために、セクション記号と図の拡張子(svg -> png)を変更しています。

はじめに

本書は Markdown の自動生成テストしたものです。サンプルとして optuna を使用し、2次関数の近似線を求めます。

最適化に使用するデータ

sample_data.png

最適化する2次多項式
y = k_2 x^2 + k_1 x + k_0

optuna で最適化した結果

BaseSampler

最適化過程

最適化後の結果

パラメータ
k0 -0.3123366018336038
k1 1.6713739299342616
k2 0.18685519487057417
mse 0.05304465728892915
elapsed_time 25.70290446281433
RandomSampler

最適化過程

最適化後の結果

パラメータ
k0 -1.0042492694679446
k1 2.3503184374400465
k2 -0.17606037160065746
mse 0.34791378366778514
elapsed_time 1.036989688873291
CmaEsSampler

最適化過程

最適化後の結果

パラメータ
k0 -0.29319171439289105
k1 1.8609057100494433
k2 0.06342857426414414
mse 0.048847462205142786
elapsed_time 2.283266067504883

MSE と 収束の速さ、最適化にかかった時間(elapsed_time) のいずれを見ても、今回の例の場合は CmaEsSampler が最も良い結果になりました。

3.1.4. コードの補足

メインの関数は param_tune 関数と write_report 関数で、それぞれ optunaによる最適化の実行と、レポートの生成を行っています。

param_tune 関数

  • optuna の使い方について簡単に紹介すると

    1. objective 関数で、最適化するパラメータと目的関数を設定します。

      def objective(trial):
          # 目的関数
          k0 = trial.suggest_float('k0', -10, 10)
          k1 = trial.suggest_float('k1', -10, 10)
          k2 = trial.suggest_float('k2', -10, 10)
          y = fx(x, {'k0': k0, 'k1': k1, 'k2': k2})
          J = mean_squared_error(y_target,y)
          return J
      
    2. 以下のように最適化を実行します。

      study = optuna.create_study(direction="minimize")
      study.optimize(objective, n_trials=1000)
      

sampler は

study = optuna.create_study(direction="minimize", sampler=sampler)

のように 与えることで最適化手法を変更できます。optuna にどのような sampler があるかは optuna.samplers に記載されています。

  • optuna の最適化結果は study.trials に記録されています。こちらから途中経過を取り出し、そのときのベストの評価値とパラメータを best_values, best_params に出力しました(参考: optuna入門)。

  • 最適化手法ごとのプロット画像と結果 (dict) については、あとでレポート生成のときに使用するのでファイル(svg画像とpickle)に出力します。

HTMLに変換する場合は、画像は png や jpeg よりもベクター画像の svg がおすすめです (ベクター画像なので拡大しても滑らかです)。

write_report 関数

  • write_report 関数で Markdown を生成します。param_tune 関数から生成した画像ファイルと、pickle に格納された結果を表に変換して埋め込んでいます。

4. まとめ

 今回 Python で Markdown のレポートを生成する方法について紹介しました。今回の例ではレポート全体を自動生成させましたが、一番面倒くさい図や表の作成&貼り付けだけを自動化させて、あとから本文を加筆するのが最も効率的だと思います。普段 Python を使っている方ならすぐにサクサク書けると思うので、おすすめです。

4.1. 今後やりたいこと

 今回は Markdown レポートを生成するところだけを紹介しましたが、この発展として、デザインのよい HTML レポートをMarkdownから生成する方法も紹介できればと思っています。

4.2. 参考

以下参考にさせていただきました。ありがとうございます。

2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6