Rudimentary optimization with APM/QA.

Added script 'apm_quality_assessment_optimize' for finding parameters
that minimize a custom function of the scores generated by APM-QA. The
script reuses the existing functionality for filtering the data on
configs/scores/outputs.

To archieve that, some modularization has been done: the part from
apm_quality_assessment_export that reads in data into a
pandas.DataFrame has been moved into quality_assessment.collect_data.

TESTED = though extensive manual tests. Unit tests for the user
scripts and 'collect_data' are missing, because we don't have a test
framework for loading/exporting fake data.

BUG=webrtc:7218

Change-Id: I5521b952970243da05fc4db1b9feef87a2e5ccad
Reviewed-on: https://chromium-review.googlesource.com/643292
Commit-Queue: Alex Loiko <aleloi@webrtc.org>
Reviewed-by: Alessio Bazzica <alessiob@webrtc.org>
Cr-Original-Commit-Position: refs/heads/master@{#19780}
Cr-Mirrored-From: https://chromium.googlesource.com/external/webrtc
Cr-Mirrored-Commit: 357429dd1ea9c53adb94c8d5e5bfcc960f3e95ee
diff --git a/modules/audio_processing/test/py_quality_assessment/BUILD.gn b/modules/audio_processing/test/py_quality_assessment/BUILD.gn
index 5b75153..cf39d72 100644
--- a/modules/audio_processing/test/py_quality_assessment/BUILD.gn
+++ b/modules/audio_processing/test/py_quality_assessment/BUILD.gn
@@ -24,6 +24,7 @@
     "apm_quality_assessment.sh",
     "apm_quality_assessment_export.py",
     "apm_quality_assessment_gencfgs.py",
+    "apm_quality_assessment_optimize.py",
   ]
   outputs = [
     "$root_build_dir/py_quality_assessment/{{source_file_part}}",
@@ -53,6 +54,7 @@
   sources = [
     "quality_assessment/__init__.py",
     "quality_assessment/audioproc_wrapper.py",
+    "quality_assessment/collect_data.py",
     "quality_assessment/data_access.py",
     "quality_assessment/echo_path_simulation.py",
     "quality_assessment/echo_path_simulation_factory.py",
diff --git a/modules/audio_processing/test/py_quality_assessment/README.md b/modules/audio_processing/test/py_quality_assessment/README.md
index 981f315..e19a780 100644
--- a/modules/audio_processing/test/py_quality_assessment/README.md
+++ b/modules/audio_processing/test/py_quality_assessment/README.md
@@ -81,7 +81,7 @@
 For instance:
 
 ```
-$ ./apm_quality_assessment-export.py \
+$ ./apm_quality_assessment_export.py \
   -o output/ \
   -c "(^default$)|(.*AE.*)" \
   -t \(white_noise\) \
diff --git a/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py b/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py
index 29618dc..2d5b7cb 100755
--- a/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py
+++ b/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_export.py
@@ -11,142 +11,13 @@
    HTML file.
 """
 
-import argparse
 import logging
-import glob
 import os
-import re
 import sys
 
-try:
-  import pandas as pd
-except ImportError:
-  logging.critical('Cannot import the third-party Python package pandas')
-  sys.exit(1)
-
-import quality_assessment.data_access as data_access
+import apm_quality_assessment_collect_data as collect_data
 import quality_assessment.export as export
-import quality_assessment.simulation as sim
 
-# Compiled regular expressions used to extract score descriptors.
-RE_CONFIG_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixApmConfig() + r'(.+)')
-RE_CAPTURE_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixCapture() + r'(.+)')
-RE_RENDER_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixRender() + r'(.+)')
-RE_ECHO_SIM_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixEchoSimulator() + r'(.+)')
-RE_TEST_DATA_GEN_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + r'(.+)')
-RE_TEST_DATA_GEN_PARAMS = re.compile(
-    sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + r'(.+)')
-RE_SCORE_NAME = re.compile(
-    sim.ApmModuleSimulator.GetPrefixScore() + r'(.+)(\..+)')
-
-
-def _InstanceArgumentsParser():
-  """Arguments parser factory.
-  """
-  parser = argparse.ArgumentParser(description=(
-      'Exports pre-computed APM module quality assessment results into HTML '
-      'tables.'))
-
-  parser.add_argument('-o', '--output_dir', required=True,
-                      help=('the same base path used with the '
-                            'apm_quality_assessment tool'))
-
-  parser.add_argument('-f', '--filename_suffix',
-                      help=('suffix of the exported file'))
-
-  parser.add_argument('-c', '--config_names', type=re.compile,
-                      help=('regular expression to filter the APM configuration'
-                            ' names'))
-
-  parser.add_argument('-i', '--capture_names', type=re.compile,
-                      help=('regular expression to filter the capture signal '
-                            'names'))
-
-  parser.add_argument('-r', '--render_names', type=re.compile,
-                      help=('regular expression to filter the render signal '
-                            'names'))
-
-  parser.add_argument('-e', '--echo_simulator_names', type=re.compile,
-                      help=('regular expression to filter the echo simulator '
-                            'names'))
-
-  parser.add_argument('-t', '--test_data_generators', type=re.compile,
-                      help=('regular expression to filter the test data '
-                            'generator names'))
-
-  parser.add_argument('-s', '--eval_scores', type=re.compile,
-                      help=('regular expression to filter the evaluation score '
-                            'names'))
-
-  return parser
-
-
-def _GetScoreDescriptors(score_filepath):
-  """Extracts a score descriptor from the given score file path.
-
-  Args:
-    score_filepath: path to the score file.
-
-  Returns:
-    A tuple of strings (APM configuration name, capture audio track name,
-    render audio track name, echo simulator name, test data generator name,
-    test data generator parameters as string, evaluation score name).
-  """
-  fields = score_filepath.split(os.sep)[-7:]
-  extract_name = lambda index, reg_expr: (
-      reg_expr.match(fields[index]).groups(0)[0])
-  return (
-      extract_name(0, RE_CONFIG_NAME),
-      extract_name(1, RE_CAPTURE_NAME),
-      extract_name(2, RE_RENDER_NAME),
-      extract_name(3, RE_ECHO_SIM_NAME),
-      extract_name(4, RE_TEST_DATA_GEN_NAME),
-      extract_name(5, RE_TEST_DATA_GEN_PARAMS),
-      extract_name(6, RE_SCORE_NAME),
-  )
-
-
-def _ExcludeScore(config_name, capture_name, render_name, echo_simulator_name,
-                  test_data_gen_name, score_name, args):
-  """Decides whether excluding a score.
-
-  A set of optional regular expressions in args is used to determine if the
-  score should be excluded (depending on its |*_name| descriptors).
-
-  Args:
-    config_name: APM configuration name.
-    capture_name: capture audio track name.
-    render_name: render audio track name.
-    echo_simulator_name: echo simulator name.
-    test_data_gen_name: test data generator name.
-    score_name: evaluation score name.
-    args: parsed arguments.
-
-  Returns:
-    A boolean.
-  """
-  value_regexpr_pairs = [
-      (config_name, args.config_names),
-      (capture_name, args.capture_names),
-      (render_name, args.render_names),
-      (echo_simulator_name, args.echo_simulator_names),
-      (test_data_gen_name, args.test_data_generators),
-      (score_name, args.eval_scores),
-  ]
-
-  # Score accepted if each value matches the corresponding regular expression.
-  for value, regexpr in value_regexpr_pairs:
-    if regexpr is None:
-      continue
-    if not regexpr.match(value):
-      return True
-
-  return False
 
 
 def _BuildOutputFilename(filename_suffix):
@@ -162,111 +33,18 @@
     return 'results.html'
   return 'results-{}.html'.format(filename_suffix)
 
-
-def _FindScores(src_path, args):
-  """Given a search path, find scores and return a DataFrame object.
-
-  Args:
-    src_path: Search path pattern.
-    args: parsed arguments.
-
-  Returns:
-    A DataFrame object.
-  """
-  # Get scores.
-  scores = []
-  for score_filepath in glob.iglob(src_path):
-    # Extract score descriptor fields from the path.
-    (config_name,
-     capture_name,
-     render_name,
-     echo_simulator_name,
-     test_data_gen_name,
-     test_data_gen_params,
-     score_name) = _GetScoreDescriptors(score_filepath)
-
-    # Ignore the score if required.
-    if _ExcludeScore(
-        config_name,
-        capture_name,
-        render_name,
-        echo_simulator_name,
-        test_data_gen_name,
-        score_name,
-        args):
-      logging.info(
-          'ignored score: %s %s %s %s %s %s',
-          config_name,
-          capture_name,
-          render_name,
-          echo_simulator_name,
-          test_data_gen_name,
-          score_name)
-      continue
-
-    # Read metadata and score.
-    metadata = data_access.Metadata.LoadAudioTestDataPaths(
-        os.path.split(score_filepath)[0])
-    score = data_access.ScoreFile.Load(score_filepath)
-
-    # Add a score with its descriptor fields.
-    scores.append((
-        metadata['clean_capture_input_filepath'],
-        metadata['echo_free_capture_filepath'],
-        metadata['echo_filepath'],
-        metadata['render_filepath'],
-        metadata['capture_filepath'],
-        metadata['apm_output_filepath'],
-        metadata['apm_reference_filepath'],
-        config_name,
-        capture_name,
-        render_name,
-        echo_simulator_name,
-        test_data_gen_name,
-        test_data_gen_params,
-        score_name,
-        score,
-    ))
-
-  return pd.DataFrame(
-      data=scores,
-      columns=(
-          'clean_capture_input_filepath',
-          'echo_free_capture_filepath',
-          'echo_filepath',
-          'render_filepath',
-          'capture_filepath',
-          'apm_output_filepath',
-          'apm_reference_filepath',
-          'apm_config',
-          'capture',
-          'render',
-          'echo_simulator',
-          'test_data_gen',
-          'test_data_gen_params',
-          'eval_score_name',
-          'score',
-      ))
-
-
 def main():
   # Init.
   logging.basicConfig(level=logging.DEBUG)  # TODO(alessio): INFO once debugged.
-  parser = _InstanceArgumentsParser()
+  parser = collect_data.InstanceArgumentsParser()
+  parser.description = ('Exports pre-computed APM module quality assessment '
+                        'results into HTML tables')
   args = parser.parse_args()
 
   # Get the scores.
-  src_path = os.path.join(
-      args.output_dir,
-      sim.ApmModuleSimulator.GetPrefixApmConfig() + '*',
-      sim.ApmModuleSimulator.GetPrefixCapture() + '*',
-      sim.ApmModuleSimulator.GetPrefixRender() + '*',
-      sim.ApmModuleSimulator.GetPrefixEchoSimulator() + '*',
-      sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + '*',
-      sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + '*',
-      sim.ApmModuleSimulator.GetPrefixScore() + '*')
+  src_path = collect_data.ConstructSrcPath(args)
   logging.debug(src_path)
-  scores_data_frame = _FindScores(src_path, args)
+  scores_data_frame = collect_data.FindScores(src_path, args)
 
   # Export.
   output_filepath = os.path.join(args.output_dir, _BuildOutputFilename(
diff --git a/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_optimize.py b/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_optimize.py
new file mode 100644
index 0000000..7946fe2
--- /dev/null
+++ b/modules/audio_processing/test/py_quality_assessment/apm_quality_assessment_optimize.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python
+# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved.
+#
+# Use of this source code is governed by a BSD-style license
+# that can be found in the LICENSE file in the root of the source
+# tree. An additional intellectual property rights grant can be found
+# in the file PATENTS.  All contributing project authors may
+# be found in the AUTHORS file in the root of the source tree.
+
+"""Finds the APM configuration that maximizes a provided metric by
+parsing the output generated apm_quality_assessment.py.
+"""
+
+from __future__ import division
+
+import collections
+import logging
+import os
+
+import quality_assessment.data_access as data_access
+import quality_assessment.collect_data as collect_data
+
+def _InstanceArgumentsParser():
+  """Arguments parser factory. Extends the arguments from 'collect_data'
+  with a few extra for selecting what parameters to optimize for.
+  """
+  parser = collect_data.InstanceArgumentsParser()
+  parser.description = (
+      'Rudimentary optimization of a function over different parameter'
+      'combinations.')
+
+  parser.add_argument('-n', '--config_dir', required=False,
+                      help=('path to the folder with the configuration files'),
+                      default='apm_configs')
+
+  parser.add_argument('-p', '--params', required=True, nargs='+',
+                      help=('parameters to parse from the config files in'
+                            'config_dir'))
+
+  parser.add_argument('-z', '--params_not_to_optimize', required=False,
+                      nargs='+', default=[],
+                      help=('parameters from `params` not to be optimized for'))
+
+  return parser
+
+
+def _ConfigurationAndScores(data_frame, params,
+                            params_not_to_optimize, config_dir):
+  """Returns a list of all configurations and scores.
+
+  Args:
+    data_frame: A pandas data frame with the scores and config name
+                returned by _FindScores.
+    params: The parameter names to parse from configs the config
+            directory
+
+    params_not_to_optimize: The parameter names which shouldn't affect
+                            the optimal parameter
+                            selection. E.g., fixed settings and not
+                            tunable parameters.
+
+    config_dir: Path to folder with config files.
+
+  Returns:
+    Dictionary of the form
+    {param_combination: [{params: {param1: value1, ...},
+                          scores: {score1: value1, ...}}]}.
+
+    The key `param_combination` runs over all parameter combinations
+    of the parameters in `params` and not in
+    `params_not_to_optimize`. A corresponding value is a list of all
+    param combinations for params in `params_not_to_optimize` and
+    their scores.
+  """
+  results = collections.defaultdict(list)
+  config_names = data_frame['apm_config'].drop_duplicates().values.tolist()
+  score_names = data_frame['eval_score_name'].drop_duplicates().values.tolist()
+
+  # Normalize the scores
+  normalization_constants = {}
+  for score_name in score_names:
+    scores = data_frame[data_frame.eval_score_name == score_name].score
+    normalization_constants[score_name] = max(scores)
+
+  params_to_optimize = [p for p in params if p not in params_not_to_optimize]
+  param_combination = collections.namedtuple("ParamCombination",
+                                            params_to_optimize)
+
+  for config_name in config_names:
+    config_json = data_access.AudioProcConfigFile.Load(
+        os.path.join(config_dir, config_name + ".json"))
+    scores = {}
+    data_cell = data_frame[data_frame.apm_config == config_name]
+    for score_name in score_names:
+      data_cell_scores = data_cell[data_cell.eval_score_name ==
+                                   score_name].score
+      scores[score_name] = sum(data_cell_scores) / len(data_cell_scores)
+      scores[score_name] /= normalization_constants[score_name]
+
+    result = {'scores': scores, 'params': {}}
+    config_optimize_params = {}
+    for param in params:
+      if param in params_to_optimize:
+        config_optimize_params[param] = config_json['-' + param]
+      else:
+        result['params'][param] = config_json['-' + param]
+
+    current_param_combination = param_combination( # pylint: disable=star-args
+        **config_optimize_params)
+    results[current_param_combination].append(result)
+  return results
+
+
+def _FindOptimalParameter(configs_and_scores, score_weighting):
+  """Finds the config producing the maximal score.
+
+  Args:
+    configs_and_scores: structure of the form returned by
+                        _ConfigurationAndScores
+
+    score_weighting: a function to weight together all score values of
+                     the form [{params: {param1: value1, ...}, scores:
+                                {score1: value1, ...}}] into a numeric
+                     value
+  Returns:
+    the config that has the largest values of |score_weighting| applied
+    to its scores.
+  """
+
+  min_score = float('+inf')
+  best_params = None
+  for config in configs_and_scores:
+    scores_and_params = configs_and_scores[config]
+    current_score = score_weighting(scores_and_params)
+    if current_score < min_score:
+      min_score = current_score
+      best_params = config
+      logging.debug("Score: %f", current_score)
+      logging.debug("Config: %s", str(config))
+  return best_params
+
+
+def _ExampleWeighting(scores_and_configs):
+  """Example argument to `_FindOptimalParameter`
+  Args:
+    scores_and_configs: a list of configs and scores, in the form
+                        described in _FindOptimalParameter
+  Returns:
+    numeric value, the sum of all scores
+  """
+  res = 0
+  for score_config in scores_and_configs:
+    res += sum(score_config['scores'].values())
+  return res
+
+
+def main():
+  # Init.
+  # TODO(alessiob): INFO once debugged.
+  logging.basicConfig(level=logging.DEBUG)
+  parser = _InstanceArgumentsParser()
+  args = parser.parse_args()
+
+  # Get the scores.
+  src_path = collect_data.ConstructSrcPath(args)
+  logging.debug('Src path <%s>', src_path)
+  scores_data_frame = collect_data.FindScores(src_path, args)
+  all_scores = _ConfigurationAndScores(scores_data_frame,
+                                       args.params,
+                                       args.params_not_to_optimize,
+                                       args.config_dir)
+
+  opt_param = _FindOptimalParameter(all_scores, _ExampleWeighting)
+
+  logging.info('Optimal parameter combination: <%s>', opt_param)
+  logging.info('It\'s score values: <%s>', all_scores[opt_param])
+
+if __name__ == "__main__":
+  main()
diff --git a/modules/audio_processing/test/py_quality_assessment/quality_assessment/collect_data.py b/modules/audio_processing/test/py_quality_assessment/quality_assessment/collect_data.py
new file mode 100644
index 0000000..bcad7a4
--- /dev/null
+++ b/modules/audio_processing/test/py_quality_assessment/quality_assessment/collect_data.py
@@ -0,0 +1,244 @@
+# Copyright (c) 2017 The WebRTC project authors. All Rights Reserved.
+#
+# Use of this source code is governed by a BSD-style license
+# that can be found in the LICENSE file in the root of the source
+# tree. An additional intellectual property rights grant can be found
+# in the file PATENTS.  All contributing project authors may
+# be found in the AUTHORS file in the root of the source tree.
+
+"""Imports a filtered subset of the scores and configurations computed
+by apm_quality_assessment.py into a pandas data frame.
+"""
+
+import argparse
+import glob
+import logging
+import os
+import re
+import sys
+
+try:
+  import pandas as pd
+except ImportError:
+  logging.critical('Cannot import the third-party Python package pandas')
+  sys.exit(1)
+
+from . import data_access as data_access
+from . import simulation as sim
+
+# Compiled regular expressions used to extract score descriptors.
+RE_CONFIG_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixApmConfig() + r'(.+)')
+RE_CAPTURE_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixCapture() + r'(.+)')
+RE_RENDER_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixRender() + r'(.+)')
+RE_ECHO_SIM_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixEchoSimulator() + r'(.+)')
+RE_TEST_DATA_GEN_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + r'(.+)')
+RE_TEST_DATA_GEN_PARAMS = re.compile(
+    sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + r'(.+)')
+RE_SCORE_NAME = re.compile(
+    sim.ApmModuleSimulator.GetPrefixScore() + r'(.+)(\..+)')
+
+def InstanceArgumentsParser():
+  """Arguments parser factory.
+  """
+  parser = argparse.ArgumentParser(description=(
+      'Override this description in a user script by changing'
+      ' `parser.description` of the returned parser.'))
+
+  parser.add_argument('-o', '--output_dir', required=True,
+                      help=('the same base path used with the '
+                            'apm_quality_assessment tool'))
+
+  parser.add_argument('-f', '--filename_suffix',
+                      help=('suffix of the exported file'))
+
+  parser.add_argument('-c', '--config_names', type=re.compile,
+                      help=('regular expression to filter the APM configuration'
+                            ' names'))
+
+  parser.add_argument('-i', '--capture_names', type=re.compile,
+                      help=('regular expression to filter the capture signal '
+                            'names'))
+
+  parser.add_argument('-r', '--render_names', type=re.compile,
+                      help=('regular expression to filter the render signal '
+                            'names'))
+
+  parser.add_argument('-e', '--echo_simulator_names', type=re.compile,
+                      help=('regular expression to filter the echo simulator '
+                            'names'))
+
+  parser.add_argument('-t', '--test_data_generators', type=re.compile,
+                      help=('regular expression to filter the test data '
+                            'generator names'))
+
+  parser.add_argument('-s', '--eval_scores', type=re.compile,
+                      help=('regular expression to filter the evaluation score '
+                            'names'))
+
+  return parser
+
+
+def _GetScoreDescriptors(score_filepath):
+  """Extracts a score descriptor from the given score file path.
+
+  Args:
+    score_filepath: path to the score file.
+
+  Returns:
+    A tuple of strings (APM configuration name, capture audio track name,
+    render audio track name, echo simulator name, test data generator name,
+    test data generator parameters as string, evaluation score name).
+  """
+  fields = score_filepath.split(os.sep)[-7:]
+  extract_name = lambda index, reg_expr: (
+      reg_expr.match(fields[index]).groups(0)[0])
+  return (
+      extract_name(0, RE_CONFIG_NAME),
+      extract_name(1, RE_CAPTURE_NAME),
+      extract_name(2, RE_RENDER_NAME),
+      extract_name(3, RE_ECHO_SIM_NAME),
+      extract_name(4, RE_TEST_DATA_GEN_NAME),
+      extract_name(5, RE_TEST_DATA_GEN_PARAMS),
+      extract_name(6, RE_SCORE_NAME),
+  )
+
+
+def _ExcludeScore(config_name, capture_name, render_name, echo_simulator_name,
+                  test_data_gen_name, score_name, args):
+  """Decides whether excluding a score.
+
+  A set of optional regular expressions in args is used to determine if the
+  score should be excluded (depending on its |*_name| descriptors).
+
+  Args:
+    config_name: APM configuration name.
+    capture_name: capture audio track name.
+    render_name: render audio track name.
+    echo_simulator_name: echo simulator name.
+    test_data_gen_name: test data generator name.
+    score_name: evaluation score name.
+    args: parsed arguments.
+
+  Returns:
+    A boolean.
+  """
+  value_regexpr_pairs = [
+      (config_name, args.config_names),
+      (capture_name, args.capture_names),
+      (render_name, args.render_names),
+      (echo_simulator_name, args.echo_simulator_names),
+      (test_data_gen_name, args.test_data_generators),
+      (score_name, args.eval_scores),
+  ]
+
+  # Score accepted if each value matches the corresponding regular expression.
+  for value, regexpr in value_regexpr_pairs:
+    if regexpr is None:
+      continue
+    if not regexpr.match(value):
+      return True
+
+  return False
+
+
+def FindScores(src_path, args):
+  """Given a search path, find scores and return a DataFrame object.
+
+  Args:
+    src_path: Search path pattern.
+    args: parsed arguments.
+
+  Returns:
+    A DataFrame object.
+  """
+  # Get scores.
+  scores = []
+  for score_filepath in glob.iglob(src_path):
+    # Extract score descriptor fields from the path.
+    (config_name,
+     capture_name,
+     render_name,
+     echo_simulator_name,
+     test_data_gen_name,
+     test_data_gen_params,
+     score_name) = _GetScoreDescriptors(score_filepath)
+
+    # Ignore the score if required.
+    if _ExcludeScore(
+        config_name,
+        capture_name,
+        render_name,
+        echo_simulator_name,
+        test_data_gen_name,
+        score_name,
+        args):
+      logging.info(
+          'ignored score: %s %s %s %s %s %s',
+          config_name,
+          capture_name,
+          render_name,
+          echo_simulator_name,
+          test_data_gen_name,
+          score_name)
+      continue
+
+    # Read metadata and score.
+    metadata = data_access.Metadata.LoadAudioTestDataPaths(
+        os.path.split(score_filepath)[0])
+    score = data_access.ScoreFile.Load(score_filepath)
+
+    # Add a score with its descriptor fields.
+    scores.append((
+        metadata['clean_capture_input_filepath'],
+        metadata['echo_free_capture_filepath'],
+        metadata['echo_filepath'],
+        metadata['render_filepath'],
+        metadata['capture_filepath'],
+        metadata['apm_output_filepath'],
+        metadata['apm_reference_filepath'],
+        config_name,
+        capture_name,
+        render_name,
+        echo_simulator_name,
+        test_data_gen_name,
+        test_data_gen_params,
+        score_name,
+        score,
+    ))
+
+  return pd.DataFrame(
+      data=scores,
+      columns=(
+          'clean_capture_input_filepath',
+          'echo_free_capture_filepath',
+          'echo_filepath',
+          'render_filepath',
+          'capture_filepath',
+          'apm_output_filepath',
+          'apm_reference_filepath',
+          'apm_config',
+          'capture',
+          'render',
+          'echo_simulator',
+          'test_data_gen',
+          'test_data_gen_params',
+          'eval_score_name',
+          'score',
+      ))
+
+
+def ConstructSrcPath(args):
+  return os.path.join(
+      args.output_dir,
+      sim.ApmModuleSimulator.GetPrefixApmConfig() + '*',
+      sim.ApmModuleSimulator.GetPrefixCapture() + '*',
+      sim.ApmModuleSimulator.GetPrefixRender() + '*',
+      sim.ApmModuleSimulator.GetPrefixEchoSimulator() + '*',
+      sim.ApmModuleSimulator.GetPrefixTestDataGenerator() + '*',
+      sim.ApmModuleSimulator.GetPrefixTestDataGeneratorParameters() + '*',
+      sim.ApmModuleSimulator.GetPrefixScore() + '*')
diff --git a/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py b/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py
index 720cb9b..7bd226b 100644
--- a/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py
+++ b/modules/audio_processing/test/py_quality_assessment/quality_assessment/export.py
@@ -6,6 +6,7 @@
 # in the file PATENTS.  All contributing project authors may
 # be found in the AUTHORS file in the root of the source tree.
 
+import functools
 import hashlib
 import os
 import re
@@ -79,7 +80,8 @@
 
   def _BuildBody(self):
     """Builds the content of the <body> section."""
-    score_names = self._scores_data_frame.eval_score_name.unique().tolist()
+    score_names = self._scores_data_frame['eval_score_name'].drop_duplicates(
+    ).values.tolist()
 
     html = [
         ('<div class="mdl-layout mdl-js-layout mdl-layout--fixed-header '
@@ -178,7 +180,7 @@
         score_name + test_data_gen + test_data_gen_params + apm_config)
     if stats['count'] == 1:
       # Show the only available score.
-      item_id = hashlib.md5(items_id_prefix).hexdigest()
+      item_id = hashlib.md5(items_id_prefix.encode('utf-8')).hexdigest()
       html.append('<div id="single-value-{0}">{1:f}</div>'.format(
           item_id, scores['score'].mean()))
       html.append('<div class="mdl-tooltip" data-mdl-for="single-value-{}">{}'
@@ -186,7 +188,8 @@
     else:
       # Show stats.
       for stat_name in ['min', 'max', 'mean', 'std dev']:
-        item_id = hashlib.md5(items_id_prefix + stat_name).hexdigest()
+        item_id = hashlib.md5(
+            (items_id_prefix + stat_name).encode('utf-8')).hexdigest()
         html.append('<div id="stats-{0}">{1:f}</div>'.format(
             item_id, stats[stat_name]))
         html.append('<div class="mdl-tooltip" data-mdl-for="stats-{}">{}'
@@ -289,7 +292,7 @@
     masks.append(self._scores_data_frame.test_data_gen == test_data_gen)
     masks.append(
         self._scores_data_frame.test_data_gen_params == test_data_gen_params)
-    mask = reduce((lambda i1, i2: i1 & i2), masks)
+    mask = functools.reduce((lambda i1, i2: i1 & i2), masks)
     del masks
     return self._scores_data_frame[mask]
 
@@ -302,7 +305,7 @@
     masks.append(scores.capture == capture)
     masks.append(scores.render == render)
     masks.append(scores.echo_simulator == echo_simulator)
-    mask = reduce((lambda i1, i2: i1 & i2), masks)
+    mask = functools.reduce((lambda i1, i2: i1 & i2), masks)
     del masks
 
     sliced_data = scores[mask]
@@ -333,7 +336,7 @@
     return 'score-stats-dialog-' + hashlib.md5(
         'score-stats-inspector-{}-{}-{}-{}'.format(
             score_name, apm_config, test_data_gen,
-            test_data_gen_params)).hexdigest()
+            test_data_gen_params).encode('utf-8')).hexdigest()
 
   @classmethod
   def _Save(cls, output_filepath, html):