Интерпретируемое машинное обучение — популярная тема в последние годы. Во многом благодаря использованию этой технологии в медицине, транспорте и других областях, где цена ошибки велика, нужно понимать, как модель устроена и чем "руководствуется" при принятии решений.
Простота объяснения зависит от сложности модели. Куда проще понять, как работает дерево принятия решений, чем извлечь какие-то определенные правила из весов полносвязной нейронки. К счастью, каскады Хаара имеют довольно простую структуру и можно, последовательно применяя их к изображению, узнать, как работает модель.
Парсинг xml-файла
Начнем с начала. OpenCV работает с каскадами, сохраненными в xml. Автор статьи помог разобраться, как этот файл устроен. Давайте посмотрим.
Сперва идет описание каскада. Будем использовать детектор лиц из OpenCV. stageType говорит нам, что каскады являются бустингом. featureType — тип признаков, а height и width — высота и ширина признаков, используемых классификаторами. maxWeakCount — максимальное количество слабых классификаторов на каждом уровне каскада. stageNum — количество уровней.
<opencv_storage> <cascade type_id="opencv-cascade-classifier"><stageType>BOOST</stageType> <featureType>HAAR</featureType> <height>24</height> <width>24</width> <stageParams> <maxWeakCount>211</maxWeakCount></stageParams> <featureParams> <maxCatCount>0</maxCatCount></featureParams> <stageNum>25</stageNum>
Что за признаки и классификаторы? Признаки — это небольшие свертки (маски), которые применяются к изображению.

Из пикселей изображения, находящихся под белой областью, вычитаются пиксели, находящиеся под черной областью
Классификаторы — это решающие деревья, которые после получения значений от сверток выдают какие-то чиселки. И в зависимости от суммы этих чиселок каскад решает, есть ли на изображении нужный предмет.
Уровни (stages) — это группы классификаторов. Каждый уровень смотрит на активацию своих классификаторов и говорит, нужно ли уточнить свои показания (перейти на следующий уровень) или пропустить изображение, потому что на нем ничего нет.
<stages> <_> <maxWeakCount>9</maxWeakCount> <stageThreshold>-5.0425500869750977e+00</stageThreshold> <weakClassifiers> <_> <internalNodes> 0 -1 0 -3.1511999666690826e-02</internalNodes> <leafValues> 2.0875380039215088e+00 -2.2172100543975830e+00</leafValues></_>
stageThreshold — порог, который нужно преодолеть классификаторам для перехода на следующий уровень. Сами же классификаторы хранятся в теге weakClassifiers. internalNodes содержит информацию об узлах дерева. Первое значение 0 — индекс текущей ноды. Второе — -1 — индекс ноды, в которую нужно перейти, переход по листьям завершается, когда индекс становится меньше 0. (На самом деле, там немного более сложная схема переходов, можно посмотреть в исходниках OpenCV.) Затем идут номер признака 0 (описания признаков — дальше в файле) и пороговое значение дерева -3.1511999666690826e-02.
В leafValues хранится информация о листьях дерева. Первое значение 2.0875380039215088e+00 — левый лист, он возвращается, если значение свертки меньше порога дерева, второе значение -2.2172100543975830e+00 — правый лист — возвращается, если значение свертки больше порога.
Теперь признаки:
<features> <_> <rects> <_> 6 4 12 9 -1.</_> <_> 6 7 12 3 3.</_></rects></_>
В теге rects хранятся прямоугольники, описывающие свертку. Первые 4 числа — x1, y1, x2, y2 — координаты противоположных вершин прямоугольника, пятое число — его "цвет". Если число отрицательное (-1), то пиксели этого прямоугольника вычитаются, если положительное (3) — складываются.
С файлом разобрались, давайте парсить:
# импортируем библиотеки from lxml import etree from multiprocessing import Pool, cpu_count import time import numpy as np from scipy.signal import convolve2d import matplotlib.pyplot as plt import cv2
cascade_path = "haarcascade_frontalface_default.xml" with open(cascade_path) as f: xml = f.read()
root = etree.fromstring(xml) cascade = root.find("cascade") width = int(cascade.find("width").text) height = int(cascade.find("height").text) features = cascade.find("features").getchildren()
# создаем массив признаков feature_matrices = np.zeros((len(features), height, width)) for i, feature in enumerate(features): cur_matrix = np.zeros((height, width)) for rect in feature.find("rects").getchildren(): line = rect.text.strip().split(" ") x1, y1, x2, y2 = map(int, line[:4]) x1, x2 = min(x1, x2), max(x1, x2) y1, y2 = min(y1, y2), max(y1, y2) c = float(line[4]) cur_matrix[y1:y2+1, x1:x2+1] = c feature_matrices[i] = cur_matrix
# выведем первый признак plt.imshow(feature_matrices[0], cmap="gray") >>>

# парсим уровни каскада в новую структуру stages = cascade.find("stages") stages_list = [] for stage in stages.getchildren(): if type(stage) == etree._Element: threshold = float(stage.find("stageThreshold").text) clfs = stage.find("weakClassifiers") classifiers = [] for clf in clfs: internal_nodes = clf.find("internalNodes").text.strip().split(" ") feature_num = int(internal_nodes[2]) feature_thresh = float(internal_nodes[3]) leafs = clf.find("leafValues").text.strip().split(" ") less_leaf = float(leafs[0]) greater_leaf = float(leafs[1]) classifiers.append([feature_num, feature_thresh, less_leaf, greater_leaf]) stages_list.append([threshold, classifiers])
len(stages_list) >>> 25
Отрисовка классификаторов
Итак, у нас 25 уровней. Давайте наложим их на картинку:
image = cv2.imread("photo.jpg", 0) image_height, image_width = image.shape[:2] plt.imshow(image) >>>

Кислотный Шерлок
for stage in stages_list: # делаем копию картинки, на которой можно будет рисовать image_copy = image.copy() for classifier in stage[1]: feature_num, thresh, less, greater = classifier # применение свертки # можно сделать и перемножением в numpy, но получается дольше activation_map = convolve2d(image, feature_matrices[feature_num], mode="valid") # в зависимости от значений листов выбираем, какой лист соответствует большей активации # и если попадаем в этот лист, то считаем, что классификатор активировался if greater > less: activation_map[activation_map < thresh] = 0 else: activation_map[activation_map > thresh] = 0 # выбираем 5 наибольших активаций по картинке k = 5 flatten_activation_map = activation_map.flatten() top_indices = np.argpartition(flatten_activation_map, -k)[-k:] # фильтруем нулевые активации top_indices = top_indices[flatten_activation_map[top_indices] > 0] for top_index in top_indices: i, j = np.unravel_index(top_index, activation_map.shape) image_part = image[i:i+height, j:j+width].astype(np.uint8) rectangle = np.ones(image_part.shape, dtype=np.uint8) * 255 # полупрозрачный прямоугольник там, где активировался классификатор res = cv2.addWeighted(image_part, 0.5, rectangle, 0.5, 1.0) image_copy[i:i+height, j:j+width] = res plt.figure() plt.imshow(image_copy, cmap="gray") plt.show()
Код выведет 25 картинок, поэтому я прикреплю только последние 2:


В ноутбуке после статьи можно посмотреть активации на разных размерах картинки:

Куда копать дальше?
Я реализовал визуализацию только одноуровневых каскадов (то есть деревьев с одной нодой и двумя листами), но это можно относительно просто исправить. А чтобы посчитать, какое значение свертки принесет наибольшую активацию, можно распарсить дерево и вытащить оттуда промежуток значений [свертки].
Помимо признаков Хаара есть и другие. Например, LBF или обобщенные признаки Хаара. Их также можно визуализировать. Также на картинке можно показывать сами признаки — отображать не белый прямоугольник, а матрицу классификатора (черно-белые области).
Весь код (ноутбук и скрипт для разбора одного каскада) оставлю на Github’е, так что его можно модифицировать, добавляя новые фичи.
Немного о нас
Еще раз привет, меня зовут Евгений. Обожаю Data science (и особенно — учить модельки *^*) и занимаюсь им полтора года. Этот пост создан благодаря нашей команде — FARADAY Lab. Мы — начинающие российские стартаперы и хотим делиться с Вами тем, что узнаем сами.
Удачи c:

Полезные ссылки:
ссылка на оригинал статьи https://habr.com/ru/post/504288/
Добавить комментарий