Skip to content

Commit 036e3f0

Browse files
authored
Merge pull request #151 from MatthewScholefield/feature/useful-hyperopt
Improve optimization functionality
2 parents ba19182 + 94d3087 commit 036e3f0

File tree

2 files changed

+225
-89
lines changed

2 files changed

+225
-89
lines changed

precise/annoyance_estimator.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Copyright 2020 Mycroft AI Inc.
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+
from collections import namedtuple
15+
from glob import glob
16+
from os.path import join
17+
18+
import numpy as np
19+
20+
from precise.params import pr
21+
from precise.util import load_audio
22+
from precise.vectorization import vectorize_raw
23+
24+
AnnoyanceEstimate = namedtuple(
25+
'AnnoyanceEstimate',
26+
'annoyance ww_annoyance nww_annoyance threshold'
27+
)
28+
29+
30+
class AnnoyanceEstimator:
31+
"""
32+
This class attempts to estimate the "annoyance" of a user
33+
of a given network. It models annoyance as follows:
34+
35+
Annoyance from false negatives (not activating when it should):
36+
We assume that the annoyance incurred by each subsequent failed
37+
activation attempt is double that of the previous attempt. ie.
38+
two failed activations causes 1 + 2 = 3 annoyance units but three
39+
failed activations causes 1 + 2 + 4 = 7 annoyance units.
40+
41+
Annoyance from false positives (activating when it should not):
42+
We assume that each false positive incurs some constant annoyance
43+
44+
With this, we can compute net annoyance from false positives
45+
and negatives individually, combine them for the total annoyance.
46+
47+
Finally, we can recompute this annoyance for each threshold
48+
value to find the threshold that yields the lowest net annoyance
49+
"""
50+
51+
def __init__(self, model, interaction_estimate, ambient_annoyance):
52+
self.thresholds = 1 / (1 + np.exp(-np.linspace(-20, 20, 1000)))
53+
self.interaction_estimate = interaction_estimate
54+
self.ambient_annoyance = ambient_annoyance
55+
56+
def compute_nww_annoyances(self, model, noise_folder, batch_size):
57+
"""
58+
Given some number, x, of ambient activations per hour, we can
59+
compute the annoyance per day from false positives as 24 * x
60+
times the annoyance incurred per ambient activation.
61+
"""
62+
nww_seconds = 0.0
63+
nww_buckets = np.zeros_like(self.thresholds)
64+
for i in glob(join(noise_folder, '*.wav')):
65+
print('Evaluating ambient activations on {}...'.format(i))
66+
inputs, audio_len = self._load_inputs(i)
67+
nww_seconds += audio_len / pr.sample_rate
68+
ambient_predictions = model.predict(inputs, batch_size=batch_size)
69+
del inputs
70+
nww_buckets += (ambient_predictions.reshape((-1, 1))
71+
> self.thresholds.reshape((1, -1))).sum(axis=0)
72+
nww_acts_per_hour = nww_buckets * 60 * 60 / nww_seconds
73+
return self.ambient_annoyance * nww_acts_per_hour * 24
74+
75+
def compute_ww_annoyances(self, ww_predictions):
76+
"""
77+
Given some proportion, p, of not recognizing the wake word, our
78+
total annoyance per interaction is modelled as p^1 * 2^0 + p^2 * 2^1
79+
+ ... + p^i * 2^(i - 1) which converges to 1 / (1 - 2p) - 1.
80+
Given some number of interactions per day we can then find the
81+
expected annoyance per day from false negatives.
82+
"""
83+
ww_buckets = (ww_predictions.reshape((-1, 1)) >
84+
self.thresholds.reshape((1, -1))).sum(axis=0)
85+
ww_fail_ratios = 1 - ww_buckets / len(ww_predictions)
86+
# Performs 1 / (1 - 2 * ww_fail_ratios) - 1, handling edge case
87+
ann_per_interaction = np.divide(
88+
1, 1 - 2 * ww_fail_ratios,
89+
where=ww_fail_ratios < 0.5
90+
) - 1
91+
ann_per_interaction[ww_fail_ratios >= 0.5] = float('inf')
92+
return self.interaction_estimate * ann_per_interaction
93+
94+
def estimate(self, model, predictions, targets, noise_folder, batch_size):
95+
"""
96+
Estimates the annoyance a model incurs according to the model
97+
described in the class documentation
98+
"""
99+
ww_predictions = predictions[np.where(targets > 0.5)]
100+
ww_annoyances = self.compute_ww_annoyances(ww_predictions)
101+
nww_annoyances = self.compute_nww_annoyances(
102+
model, noise_folder, batch_size
103+
)
104+
annoyance_by_threshold = ww_annoyances + nww_annoyances
105+
best_threshold_id = np.argmin(annoyance_by_threshold)
106+
min_annoyance = annoyance_by_threshold[best_threshold_id]
107+
return AnnoyanceEstimate(
108+
annoyance=min_annoyance,
109+
ww_annoyance=ww_annoyances[best_threshold_id],
110+
nww_annoyance=nww_annoyances[best_threshold_id],
111+
threshold=self.thresholds[best_threshold_id]
112+
)
113+
114+
@staticmethod
115+
def _load_inputs(audio_file, chunk_size=4096):
116+
"""
117+
Loads network inputs from an audio file without caching
118+
Handles data conservatively in case the audio file is large
119+
Args:
120+
audio_file: Filename to load
121+
chunk_size: Samples to skip forward when loading network inpus
122+
"""
123+
audio = load_audio(audio_file)
124+
audio_len = len(audio)
125+
mfccs = vectorize_raw(audio)
126+
del audio
127+
mfcc_hops = chunk_size // pr.hop_samples
128+
return np.array([
129+
mfccs[i - pr.n_features:i] for i in range(pr.n_features, len(mfccs), mfcc_hops)
130+
]), audio_len

precise/scripts/train_optimize.py

Lines changed: 95 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,116 +13,122 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
"""
16-
Use black box optimization to tune model hyperparameters
16+
Use black box optimization to tune model hyperparameters. Call
17+
this script in a loop to iteratively tune parameters
1718
18-
:-t --trials-name str -
19+
:trials_name str
1920
Filename to save hyperparameter optimization trials in
2021
'.bbopt.json' will automatically be appended
2122
22-
:-c --cycles int 20
23-
Number of cycles of optimization to run
23+
:noise_folder str
24+
Folder with random noise to evaluate ambient activations
2425
25-
:-m --model str .cache/optimized.net
26-
Model to load from
26+
:-ie --interaction-estimate int 100
27+
Estimated number of interactions per day
28+
29+
:-aaa --ambient-activation-annoyance float 1.0
30+
An ambient activation is X times as annoying as a failed
31+
activation when the wake word is spoken
32+
33+
:-bp --base-params str {}
34+
Json string containing base ListenerParams for all models
2735
2836
...
2937
"""
30-
import numpy
31-
# Optimizer blackhat
32-
from glob import glob
33-
from os import remove
34-
from os.path import isfile, splitext, join
35-
from pprint import pprint
38+
import json
39+
from math import exp
40+
from uuid import uuid4
41+
42+
from keras.models import save_model
3643
from prettyparse import Usage
37-
from shutil import rmtree
38-
from typing import Any
3944

45+
from precise.annoyance_estimator import AnnoyanceEstimator
4046
from precise.model import ModelParams, create_model
47+
from precise.params import pr, save_params
4148
from precise.scripts.train import TrainScript
42-
from precise.train_data import TrainData
49+
from precise.stats import Stats
4350

4451

4552
class TrainOptimizeScript(TrainScript):
46-
Usage(__doc__) | TrainScript.usage
53+
usage = Usage(__doc__) | TrainScript.usage
54+
del usage.arguments['model'] # Remove 'model' argument from original TrainScript
4755

4856
def __init__(self, args):
49-
super().__init__(args)
5057
from bbopt import BlackBoxOptimizer
58+
pr.__dict__.update(json.loads(args.base_params))
59+
args.model = args.trials_name + '-cur'
60+
save_params(args.model)
61+
super().__init__(args)
5162
self.bb = BlackBoxOptimizer(file=self.args.trials_name)
52-
if not self.test:
53-
data = TrainData.from_both(self.args.tags_file, self.args.tags_folder, self.args.folder)
54-
_, self.test = data.load(False, True)
55-
56-
from keras.callbacks import ModelCheckpoint
57-
for i in list(self.callbacks):
58-
if isinstance(i, ModelCheckpoint):
59-
self.callbacks.remove(i)
60-
61-
def process_args(self, args: Any):
62-
model_parts = glob(splitext(args.model)[0] + '.*')
63-
if len(model_parts) < 5:
64-
for name in model_parts:
65-
if isfile(name):
66-
remove(name)
67-
else:
68-
rmtree(name)
69-
args.trials_name = args.trials_name.replace('.bbopt.json', '').replace('.json', '')
70-
if not args.trials_name:
71-
if isfile(join('.cache', 'trials.bbopt.json')):
72-
remove(join('.cache', 'trials.bbopt.json'))
73-
args.trials_name = join('.cache', 'trials')
63+
64+
def calc_params_cost(self, model):
65+
"""
66+
Models the real world cost of additional model parameters
67+
Up to a certain point, having more parameters isn't worse.
68+
However, at a certain point more parameters will risk
69+
running slower than realtime and become unfeasible. This
70+
is why it's modelled exponentially with some reasonable
71+
number of acceptable parameters.
72+
73+
Ideally, this would be replaced with floating point
74+
computations and the numbers would be configurable
75+
rather than chosen relatively arbitrarily
76+
"""
77+
return 1.0 + exp((model.count_params() - 11000) / 10000)
7478

7579
def run(self):
76-
print('Writing to:', self.args.trials_name + '.bbopt.json')
77-
for i in range(self.args.cycles):
78-
self.bb.run(backend="random")
79-
print("\n= %d = (example #%d)" % (i + 1, len(self.bb.get_data()["examples"]) + 1))
80-
81-
params = ModelParams(
82-
recurrent_units=self.bb.randint("units", 1, 70, guess=50),
83-
dropout=self.bb.uniform("dropout", 0.1, 0.9, guess=0.6),
84-
extra_metrics=self.args.extra_metrics,
85-
skip_acc=self.args.no_validation,
86-
loss_bias=1.0 - self.args.sensitivity
87-
)
88-
print('Testing with:', params)
89-
model = create_model(self.args.model, params)
90-
model.fit(
91-
*self.sampled_data, batch_size=self.args.batch_size,
92-
epochs=self.epoch + self.args.epochs,
93-
validation_data=self.test * (not self.args.no_validation),
94-
callbacks=self.callbacks, initial_epoch=self.epoch
95-
)
96-
resp = model.evaluate(*self.test, batch_size=self.args.batch_size)
97-
if not isinstance(resp, (list, tuple)):
98-
resp = [resp, None]
99-
test_loss, test_acc = resp
100-
predictions = model.predict(self.test[0], batch_size=self.args.batch_size)
101-
102-
num_false_positive = numpy.sum(predictions * (1 - self.test[1]) > 0.5)
103-
num_false_negative = numpy.sum((1 - predictions) * self.test[1] > 0.5)
104-
false_positives = num_false_positive / numpy.sum(self.test[1] < 0.5)
105-
false_negatives = num_false_negative / numpy.sum(self.test[1] > 0.5)
106-
107-
from math import exp
108-
param_score = 1.0 / (1.0 + exp((model.count_params() - 11000) / 2000))
109-
fitness = param_score * (1.0 - 0.2 * false_negatives - 0.8 * false_positives)
110-
111-
self.bb.remember({
112-
"test loss": test_loss,
113-
"test accuracy": test_acc,
114-
"false positive%": false_positives,
115-
"false negative%": false_negatives,
116-
"fitness": fitness
117-
})
118-
119-
print("False positive: ", false_positives * 100, "%")
120-
121-
self.bb.maximize(fitness)
122-
pprint(self.bb.get_current_run())
123-
best_example = self.bb.get_optimal_run()
124-
print("\n= BEST = (example #%d)" % self.bb.get_data()["examples"].index(best_example))
125-
pprint(best_example)
80+
self.bb.run(alg='tree_structured_parzen_estimator')
81+
82+
model = create_model(None, ModelParams(
83+
recurrent_units=self.bb.randint("units", 1, 120, guess=30),
84+
dropout=self.bb.uniform("dropout", 0.05, 0.9, guess=0.2),
85+
extra_metrics=self.args.extra_metrics,
86+
skip_acc=self.args.no_validation,
87+
loss_bias=self.bb.uniform(
88+
'loss_bias', 0.01, 0.99, guess=1.0 - self.args.sensitivity
89+
),
90+
freeze_till=0
91+
))
92+
model.fit(
93+
*self.sampled_data, batch_size=self.args.batch_size,
94+
epochs=self.args.epochs,
95+
validation_data=self.test * (not self.args.no_validation),
96+
callbacks=[]
97+
)
98+
test_in, test_out = self.test
99+
test_pred = model.predict(test_in, batch_size=self.args.batch_size)
100+
stats_dict = Stats(test_pred, test_out, []).to_dict()
101+
102+
ann_est = AnnoyanceEstimator(
103+
model, self.args.interaction_estimate,
104+
self.args.ambient_activation_annoyance
105+
).estimate(
106+
model, test_pred, test_out,
107+
self.args.noise_folder, self.args.batch_size
108+
)
109+
params_cost = self.calc_params_cost(model)
110+
cost = ann_est.annoyance + params_cost
111+
112+
model_name = '{}-{}.net'.format(self.args.trials_name, str(uuid4()))
113+
save_model(model, model_name)
114+
save_params(model_name)
115+
116+
self.bb.remember({
117+
'test_stats': stats_dict,
118+
'best_threshold': ann_est.threshold,
119+
'cost': cost,
120+
'cost_info': {
121+
'params_cost': params_cost,
122+
'annoyance': ann_est.annoyance,
123+
'ww_annoyance': ann_est.ww_annoyance,
124+
'nww_annoyance': ann_est.nww_annoyance,
125+
},
126+
'model': model_name
127+
})
128+
print('Current Run: {}'.format(json.dumps(
129+
self.bb.get_current_run(), indent=4
130+
)))
131+
self.bb.minimize(cost)
126132

127133

128134
main = TrainOptimizeScript.run_main

0 commit comments

Comments
 (0)