You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

explain_loader.py 26 kB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. # Copyright 2020 Huawei Technologies Co., Ltd
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. # ============================================================================
  15. """ExplainLoader."""
  16. import os
  17. import re
  18. from collections import defaultdict
  19. from datetime import datetime
  20. from typing import Dict, Iterable, List, Optional, Union
  21. from mindinsight.explainer.common.enums import ExplainFieldsEnum
  22. from mindinsight.explainer.common.log import logger
  23. from mindinsight.explainer.manager.explain_parser import ExplainParser
  24. from mindinsight.datavisual.data_access.file_handler import FileHandler
  25. from mindinsight.datavisual.common.exceptions import TrainJobNotExistError
  26. from mindinsight.utils.exceptions import ParamValueError, UnknownError
  27. _NUM_DIGITS = 6
  28. _EXPLAIN_FIELD_NAMES = [
  29. ExplainFieldsEnum.SAMPLE_ID,
  30. ExplainFieldsEnum.BENCHMARK,
  31. ExplainFieldsEnum.METADATA,
  32. ]
  33. _SAMPLE_FIELD_NAMES = [
  34. ExplainFieldsEnum.GROUND_TRUTH_LABEL,
  35. ExplainFieldsEnum.INFERENCE,
  36. ExplainFieldsEnum.EXPLANATION,
  37. ]
  38. def _round(score):
  39. """Take round of a number to given precision."""
  40. return round(score, _NUM_DIGITS)
  41. class ExplainLoader:
  42. """ExplainLoader which manage the record in the summary file."""
  43. def __init__(self,
  44. loader_id: str,
  45. summary_dir: str):
  46. self._parser = ExplainParser(summary_dir)
  47. self._loader_info = {
  48. 'loader_id': loader_id,
  49. 'summary_dir': summary_dir,
  50. 'create_time': os.stat(summary_dir).st_ctime,
  51. 'update_time': os.stat(summary_dir).st_mtime,
  52. 'query_time': os.stat(summary_dir).st_ctime,
  53. 'uncertainty_enabled': False,
  54. }
  55. self._samples = defaultdict(dict)
  56. self._metadata = {'explainers': [], 'metrics': [], 'labels': []}
  57. self._benchmark = {'explainer_score': defaultdict(dict), 'label_score': defaultdict(dict)}
  58. @property
  59. def all_classes(self) -> List[Dict]:
  60. """
  61. Return a list of detailed label information, including label id, label name and sample count of each label.
  62. Returns:
  63. list[dict], a list of dict, each dict contains:
  64. - id (int): label id
  65. - label (str): label name
  66. - sample_count (int): number of samples for each label
  67. """
  68. sample_count_per_label = defaultdict(int)
  69. samples_copy = self._samples.copy()
  70. for sample in samples_copy.values():
  71. if sample.get('image', False) and sample.get('ground_truth_label', False):
  72. for label in sample['ground_truth_label']:
  73. sample_count_per_label[label] += 1
  74. all_classes_return = []
  75. for label_id, label_name in enumerate(self._metadata['labels']):
  76. single_info = {
  77. 'id': label_id,
  78. 'label': label_name,
  79. 'sample_count': sample_count_per_label[label_id]
  80. }
  81. all_classes_return.append(single_info)
  82. return all_classes_return
  83. @property
  84. def query_time(self) -> float:
  85. """Return query timestamp of explain loader."""
  86. return self._loader_info['query_time']
  87. @query_time.setter
  88. def query_time(self, new_time: Union[datetime, float]):
  89. """
  90. Update the query_time timestamp manually.
  91. Args:
  92. new_time (datetime.datetime or float): Updated query_time for the explain loader.
  93. """
  94. if isinstance(new_time, datetime):
  95. self._loader_info['query_time'] = new_time.timestamp()
  96. elif isinstance(new_time, float):
  97. self._loader_info['query_time'] = new_time
  98. else:
  99. raise TypeError('new_time should have type of datetime.datetime or float, but receive {}'
  100. .format(type(new_time)))
  101. @property
  102. def create_time(self) -> float:
  103. """Return the create timestamp of summary file."""
  104. return self._loader_info['create_time']
  105. @create_time.setter
  106. def create_time(self, new_time: Union[datetime, float]):
  107. """
  108. Update the create_time manually
  109. Args:
  110. new_time (datetime.datetime or float): Updated create_time of summary_file.
  111. """
  112. if isinstance(new_time, datetime):
  113. self._loader_info['create_time'] = new_time.timestamp()
  114. elif isinstance(new_time, float):
  115. self._loader_info['create_time'] = new_time
  116. else:
  117. raise TypeError('new_time should have type of datetime.datetime or float, but receive {}'
  118. .format(type(new_time)))
  119. @property
  120. def explainers(self) -> List[str]:
  121. """Return a list of explainer names recorded in the summary file."""
  122. return self._metadata['explainers']
  123. @property
  124. def explainer_scores(self) -> List[Dict]:
  125. """
  126. Return evaluation results for every explainer.
  127. Returns:
  128. list[dict], A list of evaluation results of each explainer. Each item contains:
  129. - explainer (str): Name of evaluated explainer.
  130. - evaluations (list[dict]): A list of evlauation results by different metrics.
  131. - class_scores (list[dict]): A list of evaluation results on different labels.
  132. Each item in the evaluations contains:
  133. - metric (str): name of metric method
  134. - score (float): evaluation result
  135. Each item in the class_scores contains:
  136. - label (str): Name of label
  137. - evaluations (list[dict]): A list of evalution results on different labels by different metrics.
  138. Each item in evaluations contains:
  139. - metric (str): Name of metric method
  140. - score (float): Evaluation scores of explainer on specific label by the metric.
  141. """
  142. explainer_scores = []
  143. for explainer, explainer_score_on_metric in self._benchmark['explainer_score'].copy().items():
  144. metric_scores = [{'metric': metric, 'score': _round(score)}
  145. for metric, score in explainer_score_on_metric.items()]
  146. label_scores = []
  147. for label, label_score_on_metric in self._benchmark['label_score'][explainer].copy().items():
  148. score_of_single_label = {
  149. 'label': self._metadata['labels'][label],
  150. 'evaluations': [
  151. {'metric': metric, 'score': _round(score)} for metric, score in label_score_on_metric.items()
  152. ],
  153. }
  154. label_scores.append(score_of_single_label)
  155. explainer_scores.append({
  156. 'explainer': explainer,
  157. 'evaluations': metric_scores,
  158. 'class_scores': label_scores,
  159. })
  160. return explainer_scores
  161. @property
  162. def labels(self) -> List[str]:
  163. """Return the label recorded in the summary."""
  164. return self._metadata['labels']
  165. @property
  166. def metrics(self) -> List[str]:
  167. """Return a list of metric names recorded in the summary file."""
  168. return self._metadata['metrics']
  169. @property
  170. def min_confidence(self) -> Optional[float]:
  171. """Return minimum confidence used to filter the predicted labels."""
  172. return None
  173. @property
  174. def sample_count(self) -> int:
  175. """
  176. Return total number of samples in the loader.
  177. Since the loader only return available samples (i.e. with original image data and ground_truth_label loaded in
  178. cache), the returned count only takes the available samples into account.
  179. Return:
  180. int, total number of available samples in the loading job.
  181. """
  182. sample_count = 0
  183. samples_copy = self._samples.copy()
  184. for sample in samples_copy.values():
  185. if sample.get('image', False) and sample.get('ground_truth_label', False):
  186. sample_count += 1
  187. return sample_count
  188. @property
  189. def samples(self) -> List[Dict]:
  190. """Return the information of all samples in the job."""
  191. return self.get_all_samples()
  192. @property
  193. def train_id(self) -> str:
  194. """Return ID of explain loader."""
  195. return self._loader_info['loader_id']
  196. @property
  197. def uncertainty_enabled(self):
  198. """Whethter uncertainty is enabled."""
  199. return self._loader_info['uncertainty_enabled']
  200. @property
  201. def update_time(self) -> float:
  202. """Return latest modification timestamp of summary file."""
  203. return self._loader_info['update_time']
  204. @update_time.setter
  205. def update_time(self, new_time: Union[datetime, float]):
  206. """
  207. Update the update_time manually.
  208. Args:
  209. new_time stamp (datetime.datetime or float): Updated time for the summary file.
  210. """
  211. if isinstance(new_time, datetime):
  212. self._loader_info['update_time'] = new_time.timestamp()
  213. elif isinstance(new_time, float):
  214. self._loader_info['update_time'] = new_time
  215. else:
  216. raise TypeError('new_time should have type of datetime.datetime or float, but receive {}'
  217. .format(type(new_time)))
  218. def load(self):
  219. """Start loading data from the latest summary file to the loader."""
  220. filenames = []
  221. for filename in FileHandler.list_dir(self._loader_info['summary_dir']):
  222. if FileHandler.is_file(FileHandler.join(self._loader_info['summary_dir'], filename)):
  223. filenames.append(filename)
  224. filenames = ExplainLoader._filter_files(filenames)
  225. if not filenames:
  226. raise TrainJobNotExistError('No summary file found in %s, explain job will be delete.'
  227. % self._loader_info['summary_dir'])
  228. is_end = False
  229. while not is_end:
  230. is_clean, is_end, event_dict = self._parser.parse_explain(filenames)
  231. if is_clean:
  232. logger.info('Summary file in %s update, reload the data in the summary.',
  233. self._loader_info['summary_dir'])
  234. self._clear_job()
  235. if event_dict:
  236. self._import_data_from_event(event_dict)
  237. def get_all_samples(self) -> List[Dict]:
  238. """
  239. Return a list of sample information cachced in the explain job
  240. Returns:
  241. sample_list (List[SampleObj]): a list of sample objects, each object
  242. consists of:
  243. - id (int): sample id
  244. - name (str): basename of image
  245. - labels (list[str]): list of labels
  246. - inferences list[dict])
  247. """
  248. returned_samples = []
  249. samples_copy = self._samples.copy()
  250. for sample_id, sample_info in samples_copy.items():
  251. if not sample_info.get('image', False) and not sample_info.get('ground_truth_label', False):
  252. continue
  253. returned_sample = {
  254. 'id': sample_id,
  255. 'name': str(sample_id),
  256. 'image': sample_info['image'],
  257. 'labels': sample_info['ground_truth_label'],
  258. }
  259. if not ExplainLoader._is_inference_valid(sample_info):
  260. continue
  261. inferences = {}
  262. for label, prob in zip(sample_info['ground_truth_label'] + sample_info['predicted_label'],
  263. sample_info['ground_truth_prob'] + sample_info['predicted_prob']):
  264. inferences[label] = {
  265. 'label': self._metadata['labels'][label],
  266. 'confidence': _round(prob),
  267. 'saliency_maps': []
  268. }
  269. if sample_info['ground_truth_prob_sd'] or sample_info['predicted_prob_sd']:
  270. for label, std, low, high in zip(
  271. sample_info['ground_truth_label'] + sample_info['predicted_label'],
  272. sample_info['ground_truth_prob_sd'] + sample_info['predicted_prob_sd'],
  273. sample_info['ground_truth_prob_itl95_low'] + sample_info['predicted_prob_itl95_low'],
  274. sample_info['ground_truth_prob_itl95_hi'] + sample_info['predicted_prob_itl95_hi']
  275. ):
  276. inferences[label]['confidence_sd'] = _round(std)
  277. inferences[label]['confidence_itl95'] = [_round(low), _round(high)]
  278. for explainer, label_heatmap_path_dict in sample_info['explanation'].items():
  279. for label, heatmap_path in label_heatmap_path_dict.items():
  280. if label in inferences:
  281. inferences[label]['saliency_maps'].append({'explainer': explainer, 'overlay': heatmap_path})
  282. returned_sample['inferences'] = list(inferences.values())
  283. returned_samples.append(returned_sample)
  284. return returned_samples
  285. def _import_data_from_event(self, event_dict: Dict):
  286. """Parse and import data from the event data."""
  287. if 'metadata' not in event_dict and self._is_metadata_empty():
  288. raise ParamValueError('metadata is imcomplete, should write metadata first in the summary.')
  289. for tag, event in event_dict.items():
  290. if tag == ExplainFieldsEnum.METADATA.value:
  291. self._import_metadata_from_event(event.metadata)
  292. elif tag == ExplainFieldsEnum.BENCHMARK.value:
  293. self._import_benchmark_from_event(event.benchmark)
  294. elif tag == ExplainFieldsEnum.SAMPLE_ID.value:
  295. self._import_sample_from_event(event)
  296. else:
  297. logger.info('Unknown ExplainField: %s', tag)
  298. def _is_metadata_empty(self):
  299. """Check whether metadata is completely loaded first."""
  300. if not self._metadata['labels']:
  301. return True
  302. return False
  303. def _import_metadata_from_event(self, metadata_event):
  304. """Import the metadata from event into loader."""
  305. def take_union(existed_list, imported_data):
  306. """Take union of existed_list and imported_data."""
  307. if isinstance(imported_data, Iterable):
  308. for sample in imported_data:
  309. if sample not in existed_list:
  310. existed_list.append(sample)
  311. take_union(self._metadata['explainers'], metadata_event.explain_method)
  312. take_union(self._metadata['metrics'], metadata_event.benchmark_method)
  313. take_union(self._metadata['labels'], metadata_event.label)
  314. def _import_benchmark_from_event(self, benchmarks):
  315. """
  316. Parse the benchmark event.
  317. Benchmark data are separeted into 'explainer_score' and 'label_score'. 'explainer_score' contains overall
  318. evaluation results of each explainer by different metrics, while 'label_score' additionally devides the results
  319. w.r.t different labels.
  320. The structure of self._benchmark['explainer_score'] demonstrates below:
  321. {
  322. explainer_1: {metric_name_1: score_1, ...},
  323. explainer_2: {metric_name_1: score_1, ...},
  324. ...
  325. }
  326. The structure of self._benchmark['label_score'] is:
  327. {
  328. explainer_1: {label_id: {metric_1: score_1, metric_2: score_2, ...}, ...},
  329. explainer_2: {label_id: {metric_1: score_1, metric_2: score_2, ...}, ...},
  330. ...
  331. }
  332. Args:
  333. benchmarks (benchmark_container): Parsed benchmarks data from summary file.
  334. """
  335. explainer_score = self._benchmark['explainer_score']
  336. label_score = self._benchmark['label_score']
  337. for benchmark in benchmarks:
  338. explainer = benchmark.explain_method
  339. metric = benchmark.benchmark_method
  340. metric_score = benchmark.total_score
  341. label_score_event = benchmark.label_score
  342. explainer_score[explainer][metric] = metric_score
  343. new_label_score_dict = ExplainLoader._score_event_to_dict(label_score_event, metric)
  344. for label, scores_of_metric in new_label_score_dict.items():
  345. if label not in label_score[explainer]:
  346. label_score[explainer][label] = {}
  347. label_score[explainer][label].update(scores_of_metric)
  348. def _import_sample_from_event(self, sample):
  349. """
  350. Parse the sample event.
  351. Detailed data of each sample are store in self._samples, identified by sample_id. Each sample data are stored
  352. in the following structure.
  353. - ground_truth_labels (list[int]): A list of ground truth labels of the sample.
  354. - ground_truth_probs (list[float]): A list of confidences of ground-truth label from black-box model.
  355. - predicted_labels (list[int]): A list of predicted labels from the black-box model.
  356. - predicted_probs (list[int]): A list of confidences w.r.t the predicted labels.
  357. - explanations (dict): Explanations is a dictionary where the each explainer name mapping to a dictionary
  358. of saliency maps. The structure of explanations demonstrates below:
  359. {
  360. explainer_name_1: {label_1: saliency_id_1, label_2: saliency_id_2, ...},
  361. explainer_name_2: {label_1: saliency_id_1, label_2: saliency_id_2, ...},
  362. ...
  363. }
  364. """
  365. if not getattr(sample, 'sample_id', None):
  366. raise ParamValueError('sample_event has no sample_id')
  367. sample_id = sample.sample_id
  368. samples_copy = self._samples.copy()
  369. if sample_id not in samples_copy:
  370. self._samples[sample_id] = {
  371. 'ground_truth_label': [],
  372. 'ground_truth_prob': [],
  373. 'ground_truth_prob_sd': [],
  374. 'ground_truth_prob_itl95_low': [],
  375. 'ground_truth_prob_itl95_hi': [],
  376. 'predicted_label': [],
  377. 'predicted_prob': [],
  378. 'predicted_prob_sd': [],
  379. 'predicted_prob_itl95_low': [],
  380. 'predicted_prob_itl95_hi': [],
  381. 'explanation': defaultdict(dict)
  382. }
  383. if sample.image_path:
  384. self._samples[sample_id]['image'] = sample.image_path
  385. for tag in _SAMPLE_FIELD_NAMES:
  386. try:
  387. if ExplainLoader._is_attr_empty(sample, tag.value):
  388. continue
  389. if tag == ExplainFieldsEnum.GROUND_TRUTH_LABEL:
  390. self._samples[sample_id]['ground_truth_label'].extend(list(sample.ground_truth_label))
  391. elif tag == ExplainFieldsEnum.INFERENCE:
  392. self._import_inference_from_event(sample, sample_id)
  393. elif tag == ExplainFieldsEnum.EXPLANATION:
  394. self._import_explanation_from_event(sample, sample_id)
  395. except UnknownError as ex:
  396. logger.warning("Parse %s data failed within image related data, detail: %r", tag, str(ex))
  397. def _import_inference_from_event(self, event, sample_id):
  398. """Parse the inference event."""
  399. inference = event.inference
  400. self._samples[sample_id]['ground_truth_prob'].extend(list(inference.ground_truth_prob))
  401. self._samples[sample_id]['ground_truth_prob_sd'].extend(list(inference.ground_truth_prob_sd))
  402. self._samples[sample_id]['ground_truth_prob_itl95_low'].extend(list(inference.ground_truth_prob_itl95_low))
  403. self._samples[sample_id]['ground_truth_prob_itl95_hi'].extend(list(inference.ground_truth_prob_itl95_hi))
  404. self._samples[sample_id]['predicted_label'].extend(list(inference.predicted_label))
  405. self._samples[sample_id]['predicted_prob'].extend(list(inference.predicted_prob))
  406. self._samples[sample_id]['predicted_prob_sd'].extend(list(inference.predicted_prob_sd))
  407. self._samples[sample_id]['predicted_prob_itl95_low'].extend(list(inference.predicted_prob_itl95_low))
  408. self._samples[sample_id]['predicted_prob_itl95_hi'].extend(list(inference.predicted_prob_itl95_hi))
  409. if self._samples[sample_id]['ground_truth_prob_sd'] or self._samples[sample_id]['predicted_prob_sd']:
  410. self._loader_info['uncertainty_enabled'] = True
  411. def _import_explanation_from_event(self, event, sample_id):
  412. """Parse the explanation event."""
  413. if self._samples[sample_id]['explanation'] is None:
  414. self._samples[sample_id]['explanation'] = defaultdict(dict)
  415. sample_explanation = self._samples[sample_id]['explanation']
  416. for explanation_item in event.explanation:
  417. explainer = explanation_item.explain_method
  418. label = explanation_item.label
  419. sample_explanation[explainer][label] = explanation_item.heatmap_path
  420. def _clear_job(self):
  421. """Clear the cached data and update the time info of the loader."""
  422. self._samples.clear()
  423. self._loader_info['create_time'] = os.stat(self._loader_info['summary_dir']).st_ctime
  424. self._loader_info['update_time'] = os.stat(self._loader_info['summary_dir']).st_mtime
  425. self._loader_info['query_time'] = max(self._loader_info['update_time'], self._loader_info['query_time'])
  426. def clear_inner_dict(outer_dict):
  427. """Clear the inner structured data of the given dict."""
  428. for item in outer_dict.values():
  429. item.clear()
  430. map(clear_inner_dict, [self._metadata, self._benchmark])
  431. @staticmethod
  432. def _filter_files(filenames):
  433. """
  434. Gets a list of summary files.
  435. Args:
  436. filenames (list[str]): File name list, like [filename1, filename2].
  437. Returns:
  438. list[str], filename list.
  439. """
  440. return list(filter(lambda filename: (re.search(r'summary\.\d+', filename) and filename.endswith("_explain")),
  441. filenames))
  442. @staticmethod
  443. def _is_attr_empty(event, attr_name) -> bool:
  444. if not getattr(event, attr_name):
  445. return True
  446. for item in getattr(event, attr_name):
  447. if not isinstance(item, list) or item:
  448. return False
  449. return True
  450. @staticmethod
  451. def _is_ground_truth_label_valid(sample_id: str, sample_info: Dict) -> bool:
  452. if len(sample_info['ground_truth_label']) != len(sample_info['ground_truth_prob']):
  453. logger.info('length of ground_truth_prob does not match the length of ground_truth_label'
  454. 'length of ground_turth_label is: %s but length of ground_truth_prob is: %s.'
  455. 'sample_id is : %s.',
  456. len(sample_info['ground_truth_label']), len(sample_info['ground_truth_prob']), sample_id)
  457. return False
  458. return True
  459. @staticmethod
  460. def _is_inference_valid(sample):
  461. """
  462. Check whether the inference data is empty or have the same length.
  463. If the probs have different length with the labels, it can be confusing when assigning each prob to label.
  464. 'is_inference_valid' return True only when the data size of match to each other. Note that prob data could be
  465. empty, so empty prob will pass the check.
  466. """
  467. ground_truth_len = len(sample['ground_truth_label'])
  468. for name in ['ground_truth_prob', 'ground_truth_prob_sd',
  469. 'ground_truth_prob_itl95_low', 'ground_truth_prob_itl95_hi']:
  470. if sample[name] and len(sample[name]) != ground_truth_len:
  471. return False
  472. predicted_len = len(sample['predicted_label'])
  473. for name in ['predicted_prob', 'predicted_prob_sd',
  474. 'predicted_prob_itl95_low', 'predicted_prob_itl95_hi']:
  475. if sample[name] and len(sample[name]) != predicted_len:
  476. return False
  477. return True
  478. @staticmethod
  479. def _is_predicted_label_valid(sample_id: str, sample_info: Dict) -> bool:
  480. if len(sample_info['predicted_label']) != len(sample_info['predicted_prob']):
  481. logger.info('length of predicted_probs does not match the length of predicted_labels'
  482. 'length of predicted_probs: %s but receive length of predicted_label: %s, sample_id: %s.',
  483. len(sample_info['predicted_prob']), len(sample_info['predicted_label']), sample_id)
  484. return False
  485. return True
  486. @staticmethod
  487. def _score_event_to_dict(label_score_event, metric) -> Dict:
  488. """Transfer metric scores per label to pre-defined structure."""
  489. new_label_score_dict = defaultdict(dict)
  490. for label_id, label_score in enumerate(label_score_event):
  491. new_label_score_dict[label_id][metric] = label_score
  492. return new_label_score_dict