機械学習による決定木分析でクラシフィケーションツリーを洗練させる

機械学習の手法の一つである決定木分析を使うと、入出力データから、対象の内部ロジックをある程度推測できるようになります。これはデバッグやテストの洗練に活用できる余地があります。

今回はその一例として、決定木分析の主要なアルゴリズムであるCART法を使って、クラシフィケーションツリー法でのクラシフィケーションツリーを洗練させるアプローチを説明します。

題材

イメージとしては、特定条件でログにワーニングやエラーが記録されるかの確認を行うような、ログ機能のテストを想定します。
テストはクラシフィケーションツリー法を使って作成します。作成したクラシフィケーションツリーは以下の通りです(処理をシンプルにするため簡略化した例を用います)。

f:id:goyoki:20200705230642p:plain

今回は、この作成したクラシフィケーションツリーが妥当かチェックするため、テスト実行時に決定木分析を使う場面を扱います。目的としては、例えばテスト設計の妥当性の確認や、リグレッションテストの洗練などを想定します。

データの取得

まず実際にテスト対象を動かして、なるべく多くの入出力データを取得します。

取得対象ですが、クラシフィケーションを取得対象データとします。また取得データはCART法を適用できるように、クラスを参考にして数値化します(例えばクラスが真偽なら1、0で記録します)。

データ取得範囲については、実行空間を全網羅する入力の全組み合わせを実現するのが理想です。ただ一般的に実現不可能なので、QuickCheckのように全体を大まかに網羅するランダムデータを生成してデータ取得します。

例えば以下のようなデータを取得します。

# インプットデータ:
input_name = ["input1", "input2", "input3", "input4"]
inputs = [
    [0, 0, 0, 88],
    [1, 1, 0, 2],
    [0, 1, 1, 26],
    [0, 0, 1, 55],
    [1, 0, 0, 29],
    [1, 1, 0, 12],
    ・・・
]
# 計測したアウトプットデータ:
# (0:"non-error", 1:"error1", 2:"error2", 3:"error3", 4:"warning1"):
output_name = ["non-error", "error1", "error2", "error3", "warning1"]
outputs = [
    0,
    4,
    0,
    4,
    4,
    3,
    ・・・
]

決定木の生成

次に取得したデータからCART法で決定木を生成します。そしてクラシフィケーションツリーの評価のため、決定木のイメージと正解率を求めます。
scikit-learnでの実装は次のようになります。

from io import StringIO
from sklearn import tree
import pydotplus
from sklearn.metrics import accuracy_score
 
def create_tree(inputs, input_name, outputs, output_name):
    clf = tree.DecisionTreeClassifier(max_depth=5) #仕様規模に応じてmax_depthを制限する。
    clf = clf.fit(inputs, outputs)

    dot_data = StringIO()
    tree.export_graphviz(clf, out_file=dot_data,
        feature_names=input_name, class_names=output_name,
        filled=True, rounded=True)
    graph = pydotplus.graph_from_dot_data(dot_data.getvalue())
    graph.write_png('./tree.png')

    predicted_outputs = clf.predict(inputs)
    accuracy = accuracy_score(outputs, predicted_outputs)
    print(f'accuracy:{accuracy}')

前述したデータで決定木を生成すると次が得られます。

f:id:goyoki:20200705230808p:plain

また得られた正解率は今回は0.73となりました。

クラシフィケーションツリーの評価と改善

そして次に、得られた決定木と正解率から、クラシフィケーションツリーを評価します。

以下に該当する場合、不具合の可能性(あくまでこの決定木は既存実装から機械学習で生成したものであり、正しい仕様に基づいたものではない点には注意が必要です)があるか、クラシフィケーション・クラスの抽出漏れの可能性があります。

  • 仕様と比べて決定木が複雑
  • 正解率が低い

 
また、以下に該当する場合、不具合あるいはデータ不足の可能性があるか、クラスの分け方に問題がある可能性があります。

  • 決定木の条件と、クラスの境界が異なる

 
また、以下に該当する場合、不具合あるいはデータ不足の可能性があるか、冗長なクラシフィケーションがある可能性があります(ただ不具合・データ不足の可能性の存在から、冗長であるとしてクラシフィケーションを削除するのは安易に行なえません)。

 

今回は決定木が複雑すぎるのと、正解率が低すぎる点が見受けられます。すなわちクラシフィケーションの抽出漏れの可能性があります。そこで、クラシフィケーションツリーを見直し、次のようにクラシフィケーションを追加します。

f:id:goyoki:20200705230933p:plain

再評価

このクラシフィケーションツリーに基づいて入出力データを再取得し、前述のコードで決定木を生成した結果が以下になります。

f:id:goyoki:20200705230953p:plain

また正解率は1.00となりました。
決定木がシンプルになったのと、正解率が高まった点から、クラシフィケーションの抽出漏れがあり、改善によりそれが是正されたと推測できます。

設計向けとテスト設計向けの適切な仕様表現の差異(デシジョンテーブルを例として)

設計向けとテスト設計向けでは、適切な仕様表現について認識に差異が出ることがあります。

題材として、以下の仕様をデシジョンテーブルで表現する場合を考えます:

題材とする仕様

  • 組み込み機器の単軸制御モード機能が対象

  • この機能は次の3つの入力を持つ
    • 省電力化モード(とり得る値:ON、OFF)
    • 制御方針(とり得る値:精密重視、速度重視)
    • 目標との距離(とり得る値:整数値[cm])

  • この機能は次の出力を持つ
    • 速度モード(とり得る値:高速モード、中速モード、低速モード)

  • この機能は出力の速度モードを次のように決定する
    • 省電力化モードがONの場合、低速モードで固定する
    • 省電力化モードがOFFの場合、以下に従う
      • 目標との距離が10cm未満の場合、低速モードを選択する
      • 目標との距離が10cm以上かつ、制御方針が精密重視なら、中速モードを選択する
      • 目標との距離が10cm以上かつ、制御方針が速度重視なら、高速モードを選択する

 

設計向けのデシジョンテーブルの例

設計では、実装の方針がわかる前提下ならば、デシジョンテーブルを簡単化しても支障はありません。むしろ必要十分な範囲内で情報を簡単化した方が、都合が良い場合が多いです。

今回の仕様では、以下のようにデシジョンテーブルを表記できます。

f:id:goyoki:20200628225659p:plain

 

テスト設計向けのデシジョンテーブルの例

一方、テスト設計向けに、デシジョンテーブル法に基づいてテーブルを作る場合、上記のようにデシジョンテーブルを簡単化できるとは限りません。どこまで簡単化できるかが、処理順序や品質リスクに依存するためです。

まず以下のような実装が行われている場合について考えます。

# 速度モード設定処理()にはモードに応じた障害のリスクがあるとする

def 単軸制御モード機能(省電力化, 制御方針, 目標との距離):
    if 省電力化 == ON:
        速度モード設定処理(低速モード) #1
        return

    if 目標との距離 < 10:
        速度モード設定処理(低速モード) #2
        return
    
    if 制御方針 == 精密重視:
        速度モード設定処理(中速モード) #3
        return
    else:
        速度モード設定処理(高速モード) #4
        return

この場合、シンプルな分岐構造で仕様が実装されているため、前述の簡単化デシジョンテーブルを使っても問題ありません。テスト技法に則り、デシジョンテーブルの各列をテストケースに展開すれば、すべての速度モード設定処理()を網羅するテストケースを用意できます。

一方で、次のような実装が行われている場合について考えます。

# 速度モード設定処理()にはモードに応じた障害のリスクがあるとする

def 単軸制御モード機能2(省電力化, 制御方針, 目標との距離):
    if 制御方針 == 精密重視:
        if 省電力化 == ON:
            速度モード設定処理(低速モード) #1
        elif 目標との距離 < 10:
            速度モード設定処理(低速モード) #2
        else:
            速度モード設定処理(中速モード) #3
        return
    else:
        if 省電力化 == ON:
            速度モード設定処理(低速モード) #4
        elif 目標との距離 < 10:
            速度モード設定処理(低速モード) #5
        else:
            速度モード設定処理(高速モード) #6
        return

この実装の場合、前述の簡単化したデシジョンテーブルに従ってテストケースを導出すると、一部の速度モード設定処理を網羅できず品質リスクが残ります。

この実装に基づいて適切にデシジョンテーブルを簡単化するならば、一例として以下のようなものになるでしょう。

f:id:goyoki:20200628225724p:plain

また機能の処理が複雑なコードで分散実装されていたり、見えにくい品質リスクが散在していたりする場合だと、実装から順序性や品質リスクが読み取れなくなる場合があります。そうなると、デシジョンテーブルは全く簡単化できない場面もあり得ます。

すなわち、設計と同じように簡単化できない場合があるということです。

なおこの話の参考文献として、 ソフトウェアテスト技法練習帳 に解説があり、お勧めできます。

 

設計向けとテスト設計向けの適切な仕様表現の差異

テストの導出元にするデシジョンテーブルをどこまで簡単化できるかは、順序性、広く言えば品質リスクに依存します。そしてテスト設計ではより包括的に品質リスクに対応する必要があることから、設計のための仕様より、簡単化できない場面が多いです。言い換えると、テスト設計では、設計と比べ、より広い視点で、情報が補強された仕様表現が求められる場面が多いです。

なお「より広い視点で、情報が補強された仕様表現が求められる」といっても、現実でそれを十分に実現できる場面は多くありません。テスト用に仕様を作り直すコストはないのが普通ですし、そもそも品質リスクは広く・見えにくく潜在していて、仕様化ですべて対応できないためです。その現実に立ち向かうため、以下のような活動が重要になってくると感じます。

  • 設計や実装でのリスクコントロールの工夫。少ないテストでも大丈夫なように作る。
  • 探索的テストによる補完。仕様の情報不足を、テストエンジニアの能力や学習結果で補完する。
  • グレーボックステストの拡充。設計やコードの構造に基づいてテスト設計の穴を補強する。
  • 無則のテストの導入。ペアワイズ法などで仕様化されていない条件もテストする。
  • 品質保証の重ね合わせ。コードレビュー、ユニットテストシステムテスト、専任チームによる品質保証などを組み合わせて、品質リスクの見落としを減らす。

UTP2(UML Testing Profile 2)でテスト設計の成果物を一通り表現する

 UMLのプロファイル(特定用途向けに、ステレオタイプ、タグ付き値、モデル要素の制約・関係性の拡張をくわえたもの)の中に、ソフトウェアテストのためのプロファイルUTP2(UML Testing Profile 2)があります。

 UTP2は、テストでモデルベースドアプローチを実現して、ソフトウェアテストの各種活動でモデリングの恩恵を受けることを目的としています。恩恵とは、例えばテスト分析・設計での思考をモデルで補助する、トレーサビリティを明示化する、テスト成果物のレビューなどの協業を支える、モデル駆動などのツールの恩恵を得る、といったものです。
 UTP2の内容は、テスト環境とテスト実行の仕様定義が多めとなっています。今後普及するかは、使い勝手の良いツールが出てくるかに依存していると思います。

 今回はUTP2の紹介として、テスト設計の一通りの活動の成果物を、UTP2で表現していきたいと思います。

前置き

 今回は以下の2.1betaに基づいてテスト設計を行います。

UML Testing Profile 2.1beta
https://www.omg.org/spec/UTP2/About-UTP2/

 なおUTP2は仕様が大きく、使用する際はテーラリングした上での部分採用の形をとることになります(実際、UTP2仕様書中のExampleは、それぞれでかなり仕様を絞り込んでいて表現がばらばらになっています)。今回の例示もUTP2の一部の仕様のみを使用しています。

題材

 今回は医療用デジタル体温計のプロダクトシステムを対象とします。

UTP2に基づいたテストの活動

テストコンテキストの定義

 UTP2ではまずテストの活動をテストコンテキストの集まりで表現します(注:ここでいうテストコンテキストは、UTP2独特の定義づけがされた言葉です)。

 テストコンテキストは、以降のテストの活動が行いやすいように、テストレベルやテストタイプなどを基準に、テストの活動を分けたものです。テストコンテキストは、テストの成果物を含む、テストの活動にかかわる情報の集合として表現されます。

 比喩を使うと、例えばシステムを開発する際は、システムをサブシステムに分割し、サブシステムごとに分かれて開発を進めます。そこでいうシステムとサブシステムの関係が、テスト活動全体とテストコンテキストの関係に当てはまります。

 テストコンテキストはTestContextをステレオタイプにもつパッケージと定義されており、パッケージ図でモデリングします。

f:id:goyoki:20200426183923p:plain

 図中のとおり、テストコンテキストはテストタイプとテストレベルをパラメータとして持っています。
 今回はこのうち、システムテストの機能正確性テストに属する、温度計測精度テストに絞ってテスト設計を進めます。

 なおテストコンテキストを定義するためには、その前にテストレベルやテストタイプの分析・計画が必要です。UTP2ではテストタイプとテストレベルをValueSpecificationと定義しており、分析はクラス図あるいはオブジェクト図で行うことになります。ただそれらテストコンテキスト定義以前の活動については、UTP2でほとんど扱われていないため今回は省きます。

テスト目的とテスト要求の分析

 次にテストコンテキストごとにテスト分析・設計を進めていきます。

 まず、テストコンテキストのテスト目的を、以降のテスト分析ができるようになるまで具体化します。テスト目的はTestObjectiveのステレオタイプを付与したクラスと定義されており、クラス図でモデリングしていきます。

f:id:goyoki:20200426183941p:plain

 テスト目的を具体化したら、その目的に基づいてテスト要求を定義します。テスト要求は、テストしたいと思えるテスト対象の特質です。テスト目的を満たすように、テスト対象の要求を参照しながら定義します。テスト要求はTestReqirementのステレオタイプを付与したクラスと定義されており、クラス図でモデリングしていきます。

f:id:goyoki:20200426183946p:plain

テスト設計

 次に、テスト要求とテスト目的からテスト設計方針を立て、その方針に基づいてテストケースを作成します。モデルにおいては、テスト設計方針にはTestDesignDirective、テストケースにはTestCaseのステレオタイプを付与して表現します。
 テスト要求・テスト設計方針・テストケースの関係性については、2.1beta時点のUTP2仕様書では仕様定義と文章による解説が中心で、一貫性を持った例示を行っていません。ただ以下のようなモデルで表現できると思います。

f:id:goyoki:20200426183928p:plain

 なお上記ではテスト要求との関係を表現するためクラス図を用いていますが、TestDesignDirectiveはインスタンスと定義されており、それ単体でモデリングする際はオブジェクト図を用いることになります。

 別の活用例として、SysMLのようにテスト要求モデルにテストケースを関連付けて、両者のトレーサビリティをモデルで表現する方法も提示されています。またテスト設計方針や、テストケースの集まりであるテストセットは、オブジェクト図やパッケージ図で構造化しながら設計を進めることもできます。

テスト環境の設計

 一方、テストを実行するためのテスト環境をモデリングします。テスト環境はTest Configurationとして表現されます。テスト対象にTestItem、テスト環境のコンポーネントにTestComponentのステレオタイプを付与して、クラス図やコンポジット構造図でモデリングします。

f:id:goyoki:20200426183919p:plain

テスト実装

 そしてテストケースごとにテストケースの手順を作成します。
 手順はTestCaseのステレオタイプを付与したふるまいモデルで表現します。今回は仕様書の例示に従ってシーケンス図でモデリングしています。

f:id:goyoki:20200426183937p:plain

Prometheusでファイルサービスディスカバリを使って監視対象を動的に変える

監視対象が動的に増減するような状況においてPrometheusで監視を行う場合、サービスティスカバリ機能が便利です。今回はその例として、Prometheusのファイルサービスディスカバリで監視対象を動的に変更する方法についてまとめます。

動作環境の構築

まずdocker-composeで最低限の環境を構築します。
次のディレクトリ構成を用意します。

docker-compose.yaml 
prometheus/
  prometheus.yaml
  targets.json

docker-compose.yamlは次の通りです。prometheusとnode-exporterのdockerコンテナを用意します。

version: '3'
services:
  prometheus:
    image: prom/prometheus
    container_name: prometheus
    volumes:
      - ./prometheus:/etc/prometheus
    command: "--config.file=/etc/prometheus/prometheus.yaml"
    ports:
      - 9090:9090
    restart: always
  exporter:
    image: prom/node-exporter:latest
    container_name: node-exporter
    ports:
      - 9100:9100
    restart: always

prometheus.yamlは次の通りです。
ファイルサービスディスカバリを使用する場合、scrape_configsにfile_sd_configsを指定し、その中のfilesにglob形式でターゲットファイルへのパスを記入します(今回は*.json。このyamlファイルからの相対パスで記述)

global:
  scrape_interval: 15s
scrape_configs:
  - job_name: 'node'
    file_sd_configs:
      - files:
        - '*.json'

すると、prometheusはglob形式で指定したファイルを探し出して監視対象に加えてくれるようになります。

targets.jsonは次の通りです。

[
  {
    "labels": {
      "job": "node"
    },
    "targets": [
      "node-exporter:9100"
    ]
  }
]

prometheusで最初の状態を確認する

「docker-compose up -d」でprometheusとnode-exporterを立ち上げます。
「http://【実行環境のアドレス】:9090/graph」にアクセスし、Expressionに「up{job="node"}」を入力してExecuteします。すると以下の結果が出力されます。

Element	Value
up{instance="node-exporter:9100",job="node"}	1

targets.jsonで指定した監視対象がTargetに加えられていることがわかります。

ターゲットファイルを追加して監視対象を変更する

次にprometheusを実行したまま監視対象を変えてみます。
targets.jsonと同じディレクトリに次のtargets2.jsonを追加します。

[
  {
    "labels": {
      "job": "node"
    },
    "targets": [
      "prometheus:9090"
    ]
  }
]

再度prometheusのgraphページにアクセスし「up{job="node"}」をExecuteします。すると以下の結果が出力されます。

Element	Value
up{instance="node-exporter:9100",job="node"}	1
up{instance="prometheus:9090",job="node"}	1

targets2.jsonでの追加分が、監視対象に反映されていることがわかります。


Github Actionでテストや静的解析に失敗するプルリクエストをマージできないようにする

Github Actionを活用するとGithub内でCIを完結できます。
その活用例として、今回はプリリクエスト作成時にユニットテストと静的解析を実行し、それらが成功しないとプルリクエストのマージを拒絶する仕組みをGithub Actionで作ります。

対象コード

対象ソースコードは以下です。

https://github.com/hiro-iseri/blog_demo

プロダクトコードをdemoディレクトリに、ユニットテストをtestsディレクトリに格納する構成をとります。

python-package.ymlの作成

/.github/workflows/ディレクトリにpython-package.ymlを作成し、Github Actionの設定を記述します。
今回の設定内容ですが、Python3.8環境にて、プロダクトコードに対し、flake8で静的解析、標準unittestでtestsディレクトリのユニットテストを実行します。
これをプルリクエストの作成時、更新時に実行するように設定します。
以下記述例です。

name: demo

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8]
    steps:
    - uses: actions/checkout@v2
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v1
      with:
        python-version: ${{ matrix.python-version }}
    - name: Lint
      run: |
        pip install flake8
        flake8 demo --show-source
    - name: Test
      run:
        python -m unittest discover tests

プルリクエスト画面で静的解析・ユニットテスト結果を表示する

上記yamlをpushすると、プルリクエストの作成・更新時に、Github Actionが実行されるようになります。
そしてプルリクエストのマージ画面で次のように静的解析・ユニットテスト結果が付記されるようになります。

f:id:goyoki:20200419195436p:plain
no_block

静的解析・ユニットテスト失敗時にプルリクエストのマージをブロックする

次に、masterブランチへのプルリクエストを対象に、静的解析・ユニットテスト失敗時のブロックを有効化します。
設定方法ですが、GithubプロジェクトのSettingsタブ→Branchesメニューを選択します。
Branch name patternの対象をmasterとし、「Require status checks to pass before merging」「Require branches to be up to date before merging」を有効化して、前述のyamlで定義したbuildにチェックを入れます。

f:id:goyoki:20200419195737p:plain

この設定を有効化すると、masterに対するプルリクエストについて、静的解析・ユニットテストが失敗するものはマージがブロックされるようになります。

f:id:goyoki:20200419200012p:plain

Nスイッチカバレッジ計測ツールを公開

状態遷移テストの補助用に、Nスイッチカバレッジを計測するツールnswitchcov_aを公開しました。

https://github.com/hiro-iseri/nswitchcov_a

使い方の例

CUIツールです。状態遷移定義ファイルと実行フローファイルの2つを入力に実行します。

具体例として、以下の状態遷移を扱います:

f:id:goyoki:20200210002948p:plain

この状態遷移を、以下のテストケースで網羅します:

  • テストケース1 :初期状態→状態1→状態4
  • テストケース2 :初期状態→状態1→状態2→状態3→初期状態
  • テストケース3 :初期状態→状態1→状態3

このケースの2スイッチカバレッジを計測します。

作成するファイル

状態遷移定義ファイル(stateflow.txt)は例えば以下のようになります

初期状態 - イベント1 > 状態1
状態1 - イベント2 > 状態2
状態1 - イベント4 > 状態3
状態1 - イベント5 > 状態4
状態2 - イベント3 > 状態3
状態3 - イベント5 > 初期状態

実行フローファイル(exepath.txt)は例えば以下のようになります

#テストケース1
初期状態 - イベント1 > 状態1 - イベント5 > 状態4
#テストケース2
初期状態 - イベント1 > 状態1 - イベント2 > 状態2 - イベント3 > 状態3 - イベント5 > 初期状態
#テストケース3
初期状態 - イベント1 > 状態1 - イベント4 > 状態3

これらを入力としてコマンドラインで実行すると、2スイッチカバレッジ25.00%の結果が得られます。

nswitchcov_a.exe --exepath=exepath.txt --stateflow=stateflow.txt --n=2
nswitchcov_a(ver:1.01)
number of execution path:3
number of n-switch path:8
n-switch coverage:25.00%(2/8)

組み込み開発での品質保証、テスト、CD/CIの未来についてのイベント開催

直前ですが、「Tomorrow’s Software Testing for Embedded Systems」というイベントを開催します。

10月23日 Tomorrow’s Software Testing for Embedded Systems(東京都)

電通大にしさんの同名の講演の再演になります。組み込み開発における品質保証、CI/CD、DevOps、モデルベース開発について講演いただくほか、組み込みの品質保証について深く語る場となります。内容にご興味ある方はぜひご参加いただけると嬉しいです。