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

# 1. はじめに

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

**本ページで紹介すること**

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

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

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

## 2.1. 開発環境

* Windows 11
* Python 3.8.10 (3.10.9でも動作を確認)
  
<details><summary>使用したパッケージ (requirements.txt) はこちら </summary>

```text: 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
```
</details>

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

### 2.2.1. Markdown 生成パッケージ

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

* [didix21/mdutils](https://github.com/didix21/mdutils)

### 2.2.2. mdutils のサンプルコード

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

```sh
$ pip install mdutils
```

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

![mdutils サンプル](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/85fdedb8-6ecf-ceaf-d265-7cfb0aeac230.png)


```python: 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のトップページ](https://github.com/didix21/mdutils) や [ドキュメント](https://mdutils.readthedocs.io/en/latest/examples/Example_Python.html) に例が豊富です。
* mdutils で Markdown を作成する流れは、以下のようになります。
    1. インスタンスを作成する。
    
        ```python
        mdFile = MdUtils(file_name="sample.md", title='Python から Markdown を生成するサンプル')
        ```
    2. Markdownに出力したい要素を上から順番に追加する。

        ```python
        # ただのテキストを追加する場合
        mdFile.new_line("テキスト")
        # 図を貼る場合
        mdFile.new_line(mdFile.new_inline_image(text='写真', path='./sample.png'))
        ```

    3. ファイルにエクスポートする。

        ```python
        mdFile.create_md_file()
        ```

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

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

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

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

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

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

　先ほどの例を 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` を探索する）
  
    ```math
    y = k_2 x^2 + k_1 x + k_0
    ```
* 評価関数としては MSE(平均二乗誤差) を使用する。
* optuna が提供するサンプラー `BaseSampler`(ベイズ最適化), `RandomSampler`(ランダム), `CmaEsSampler`(CMA-ES) ごとに以下の結果を確認する。
  * ステップごとの最適化結果
  * 最終的に得られた最適な `k_0, k_1, k_2` とその MSE
  * 最適化にかかった時間

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

```text: requirements.txt
mdutils
numpy
matplotlib
scikit-learn
optuna
```

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

### 3.1.1. コード

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

```python: 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 

~~~md: 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 レポートのレンダリング結果

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

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

##### 最適化に使用するデータ
  
![sample_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/801066ee-ebc6-3145-37b9-09dd056b5369.png)
 

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


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

#### optuna で最適化した結果

##### BaseSampler
  
![最適化過程](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/ece0e6bf-ad27-30b3-6784-4a23ab85e995.png)

![最適化後の結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/f8520161-51e2-7841-70cf-abe0c26a8775.png)


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

##### RandomSampler
  
![最適化過程](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/7aa3d6dd-a135-e251-0772-8e0e0a37aebe.png)

![最適化後の結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/536f71bf-8dd0-3b75-1bb4-9159df414916.png)


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

##### CmaEsSampler
  
![最適化過程](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/ab2e932c-1c74-eed8-0178-07419159ae82.png)
   
![最適化後の結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3130775/cd6b1429-0202-5c35-922f-a51c15b66605.png)


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

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


### 3.1.4. コードの補足

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

**`param_tune` 関数**

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

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

        ```python
        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. 以下のように最適化を実行します。

        ```python
        study = optuna.create_study(direction="minimize")
        study.optimize(objective, n_trials=1000)
        ```
    
:::note info
sampler は
```python
study = optuna.create_study(direction="minimize", sampler=sampler)
```
のように 与えることで最適化手法を変更できます。optuna にどのような sampler があるかは [optuna.samplers](https://optuna.readthedocs.io/en/stable/reference/samplers/index.html) に記載されています。
:::

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

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

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

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

# 4. まとめ

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

## 4.1. 今後やりたいこと

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

## 4.2. 参考

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

* [didix21/mdutils](https://github.com/didix21/mdutils)
* [Markdown記法 サンプル集](https://qiita.com/tbpgr/items/989c6badefff69377da7)
* [optuna入門](https://qiita.com/studio_haneya/items/2dc3ba9d7cafa36ddffa)