Skip to content

Commit 6ea168d

Browse files
authored
Merge pull request #794 from vloncar/nested_model
Support for parsing nested models
2 parents 5780a90 + 0c5ffd0 commit 6ea168d

File tree

7 files changed

+289
-5
lines changed

7 files changed

+289
-5
lines changed

hls4ml/converters/keras/model.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from hls4ml.converters.keras_to_hls import (
2+
KerasFileReader,
3+
KerasModelReader,
4+
KerasNestedFileReader,
5+
keras_handler,
6+
parse_default_keras_layer,
7+
parse_keras_model,
8+
)
9+
10+
model_layers = ['Sequential', 'Functional']
11+
12+
13+
@keras_handler(*model_layers)
14+
def parse_model_layer(keras_layer, input_names, input_shapes, data_reader):
15+
assert keras_layer['class_name'] in model_layers
16+
17+
layer = parse_default_keras_layer(keras_layer, input_names)
18+
layer['class_name'] = 'LayerGroup'
19+
20+
if isinstance(data_reader, KerasNestedFileReader):
21+
# In the .h5 file, the paths don't go more than one level deep
22+
nested_path = data_reader.nested_path
23+
else:
24+
nested_path = layer['name']
25+
26+
if isinstance(data_reader, KerasFileReader):
27+
nested_reader = KerasNestedFileReader(data_reader, nested_path)
28+
else:
29+
nested_reader = KerasModelReader(data_reader.model.get_layer(layer['name']))
30+
31+
layer_list, input_layers, output_layers, output_shapes = parse_keras_model(keras_layer, nested_reader)
32+
33+
if output_layers is None:
34+
last_layer = layer_list[-1]['name']
35+
else:
36+
last_layer = output_layers[0]
37+
output_shape = output_shapes[last_layer]
38+
39+
layer['layer_list'] = layer_list
40+
layer['input_layers'] = input_layers if input_layers is not None else []
41+
layer['output_layers'] = output_layers if output_layers is not None else []
42+
layer['data_reader'] = nested_reader
43+
layer['output_shape'] = output_shape
44+
45+
return layer, output_shape

hls4ml/converters/keras_to_hls.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ def get_weights_data(self, layer_name, var_name):
4747

4848
class KerasNestedFileReader(KerasFileReader):
4949
def __init__(self, data_reader, nested_path):
50-
self.config = data_reader.config
51-
self.h5file = h5py.File(self.config['KerasH5'], mode='r')
50+
super().__init__(data_reader.config)
5251
self.nested_path = nested_path
5352

5453
def _find_data(self, layer_name, var_name):
@@ -319,18 +318,19 @@ def parse_keras_model(model_arch, reader):
319318
inputs_map[layer['name']] = act_layer['name']
320319
if output_layers is not None and layer['name'] in output_layers:
321320
output_layers = [act_layer['name'] if name == layer['name'] else name for name in output_layers]
321+
output_shapes[act_layer['name']] = output_shape
322322
layer_list.append(act_layer)
323323

324324
assert output_shape is not None
325325

326326
output_shapes[layer['name']] = output_shape
327327

328-
return layer_list, input_layers, output_layers
328+
return layer_list, input_layers, output_layers, output_shapes
329329

330330

331331
def keras_to_hls(config):
332332
model_arch, reader = get_model_arch(config)
333-
layer_list, input_layers, output_layers = parse_keras_model(model_arch, reader)
333+
layer_list, input_layers, output_layers, _ = parse_keras_model(model_arch, reader)
334334
print('Creating HLS model')
335335
hls_model = ModelGraph(config, layer_list, input_layers, output_layers)
336336
return hls_model

hls4ml/model/layers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,24 @@ def _initialize_transforms(self):
12731273
self._output_features = self.attributes['n_out_features'][-1]
12741274

12751275

1276+
class LayerGroup(Layer):
1277+
_expected_attributes = [
1278+
Attribute('layer_list', value_type=list),
1279+
Attribute('input_layers', value_type=list),
1280+
Attribute('output_layers', value_type=list),
1281+
Attribute('data_reader', value_type=object),
1282+
Attribute('output_shape', value_type=list),
1283+
]
1284+
1285+
def initialize(self):
1286+
shape = self.get_attr('output_shape')
1287+
if shape[0] is None:
1288+
shape.pop(0)
1289+
dims = [f'N_INPUT_{self.index}_{i+1}' for i in range(len(shape))]
1290+
1291+
self.add_output_variable(shape, dims)
1292+
1293+
12761294
layer_map = {
12771295
'Input': Input,
12781296
'InputLayer': Input,
@@ -1324,6 +1342,7 @@ def _initialize_transforms(self):
13241342
'GRU': GRU,
13251343
'GarNet': GarNet,
13261344
'GarNetStack': GarNetStack,
1345+
'LayerGroup': LayerGroup,
13271346
# TensorFlow-specific layers:
13281347
'BiasAdd': BiasAdd,
13291348
}

hls4ml/model/optimizer/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
'channels_last_converter',
3737
'fuse_bias_add',
3838
'remove_useless_transpose',
39+
'expand_layer_group',
3940
'output_rounding_saturation_mode',
4041
'qkeras_factorize_alpha',
4142
'extract_ternary_threshold',
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from hls4ml.model.layers import Input, LayerGroup
2+
from hls4ml.model.optimizer import OptimizerPass
3+
4+
5+
class ExpandLayerGroup(OptimizerPass):
6+
'''Expands LayerGroup (a nested model) into the parent model.'''
7+
8+
def match(self, node):
9+
return isinstance(node, LayerGroup)
10+
11+
def transform(self, model, node):
12+
layer_list = node.get_attr('layer_list')
13+
14+
# We'll keep track of inserted Input nodes to remove later
15+
inserted_input_nodes = []
16+
17+
for i, layer in enumerate(layer_list):
18+
kind = layer['class_name']
19+
name = layer['name']
20+
inputs = layer.get('inputs', [])
21+
outputs = layer.get('outputs', [])
22+
23+
if name in model.graph.keys():
24+
raise Exception(f'Layer names must be unique: "{name}" already found in the model graph.')
25+
26+
if len(inputs) == 0:
27+
if kind in ['InputLayer', 'Input']:
28+
inputs = node.inputs.copy()
29+
else:
30+
inputs = model.graph[layer_list[i - 1]['name']].outputs.copy()
31+
if len(outputs) == 0:
32+
outputs = [name]
33+
34+
new_node = model.make_node(kind, name, layer, inputs, outputs)
35+
model.insert_node(new_node)
36+
if isinstance(new_node, Input):
37+
inserted_input_nodes.append(new_node)
38+
39+
rewire = not node.outputs[0] in model.outputs
40+
41+
model.remove_node(node, rewire)
42+
43+
for input_node in inserted_input_nodes:
44+
model.remove_node(input_node, rewire=True)
45+
46+
return True

hls4ml/utils/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def config_from_keras_model(
133133

134134
reader = hls4ml.converters.KerasModelReader(model)
135135

136-
layer_list, _, _ = hls4ml.converters.parse_keras_model(model_arch, reader)
136+
layer_list, _, _, _ = hls4ml.converters.parse_keras_model(model_arch, reader)
137137

138138
def make_layer_config(layer):
139139
cls_name = layer['class_name']
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
""" Test that nested models in Keras is properly parsed and expanded by the optimizers.
2+
"""
3+
4+
from pathlib import Path
5+
6+
import numpy as np
7+
import pytest
8+
from tensorflow.keras.layers import Dense, Input
9+
from tensorflow.keras.models import Model, Sequential
10+
11+
import hls4ml
12+
13+
test_root_path = Path(__file__).parent
14+
15+
16+
def make_nested_model(input_shape):
17+
"""
18+
This model will have the following architecture:
19+
Functional (fun_model)
20+
Dense (fun_first_dense)
21+
Sequential (seq_sub)
22+
Dense
23+
Dense
24+
Dense (fun_middle_dense)
25+
Functional (fun_sub)
26+
Dense
27+
Dense
28+
Dense (fun_last_dense)
29+
"""
30+
seq_sub = Sequential(name='seq_sub')
31+
seq_sub.add(Dense(5, activation='linear', input_shape=(5,), name='seq_sub_dense_1'))
32+
seq_sub.add(Dense(3, activation='linear', name='seq_sub_dense_2'))
33+
34+
fun_input = Input(shape=(8,), name='fun_input')
35+
fun_x = Dense(7, activation='linear', name='fun_sub_dense_1')(fun_input)
36+
fun_x = Dense(6, activation='linear', name='fun_sub_dense_2')(fun_x)
37+
fun_sub = Model(inputs=fun_input, outputs=fun_x, name='fun_sub')
38+
39+
input = Input(shape=input_shape, name='model_input')
40+
x = Dense(5, activation='linear', name='fun_first_dense')(input)
41+
x = seq_sub(x)
42+
x = Dense(8, activation='linear', name='fun_middle_dense')(x)
43+
x = fun_sub(x)
44+
x = Dense(4, activation='linear', name='fun_last_dense')(x)
45+
fun_model = Model(inputs=input, outputs=x, name='fun_model')
46+
47+
return fun_model
48+
49+
50+
def make_sub_nested_model(input_shape):
51+
"""
52+
The following abomination will create this hierarchy:
53+
Sequential
54+
Dense (first_dense)
55+
Functional (fun_model)
56+
Dense (fun_first_dense)
57+
Sequential (fun_model_seq_sub)
58+
Dense
59+
Dense
60+
Dense (fun_middle_dense)
61+
Functional (fun_model_fun_sub)
62+
Dense
63+
Dense
64+
Dense (fun_last_dense)
65+
Dense (middle_dense)
66+
Sequential (seq_model)
67+
Dense
68+
Functional (seq_model_fun_sub)
69+
Dense
70+
Dense
71+
Dense
72+
Sequential (seq_model_seq_sub)
73+
Dense
74+
Dense
75+
Dense
76+
Dense (last_dense)
77+
"""
78+
fun_model_seq_sub = Sequential(name='fun_model_seq_sub')
79+
fun_model_seq_sub.add(Dense(5, activation='linear', input_shape=(5,), name='fun_seq_sub_dense_1'))
80+
fun_model_seq_sub.add(Dense(3, activation='linear', name='fun_seq_sub_dense_2'))
81+
82+
fun_fun_input = Input(shape=(8,), name='fun_fun_input')
83+
fun_fun_x = Dense(7, activation='linear', name='fun_fun_sub_dense_1')(fun_fun_input)
84+
fun_fun_x = Dense(6, activation='linear', name='fun_fun_sub_dense_2')(fun_fun_x)
85+
fun_model_fun_sub = Model(inputs=fun_fun_input, outputs=fun_fun_x, name='fun_model_fun_sub')
86+
87+
fun_input = Input(shape=(10,), name='fun_input')
88+
fun_x = Dense(5, activation='linear', name='fun_first_dense')(fun_input)
89+
fun_x = fun_model_seq_sub(fun_x)
90+
fun_x = Dense(8, activation='linear', name='fun_middle_dense')(fun_x)
91+
fun_x = fun_model_fun_sub(fun_x)
92+
fun_x = Dense(4, activation='linear', name='fun_last_dense')(fun_x)
93+
fun_model = Model(inputs=fun_input, outputs=fun_x, name='fun_model')
94+
95+
seq_fun_input = Input(shape=(2,), name='seq_fun_input')
96+
seq_fun_x = Dense(9, activation='linear', name='seq_fun_sub_dense_1')(seq_fun_input)
97+
seq_fun_x = Dense(3, activation='linear', name='seq_fun_sub_dense_2')(seq_fun_x)
98+
seq_model_fun_sub = Model(inputs=seq_fun_input, outputs=seq_fun_x, name='seq_model_fun_sub')
99+
100+
seq_model_seq_sub = Sequential(name='seq_model_seq_sub')
101+
seq_model_seq_sub.add(Dense(5, activation='linear', input_shape=(2,), name='seq_seq_sub_dense_1'))
102+
seq_model_seq_sub.add(Dense(7, activation='linear', name='seq_seq_sub_dense_2'))
103+
104+
seq_model = Sequential(name='seq_model')
105+
seq_model.add(Dense(2, activation='linear', input_shape=(6,), name='seq_first_dense'))
106+
seq_model.add(seq_model_fun_sub)
107+
seq_model.add(Dense(2, activation='linear', name='seq_middle_dense'))
108+
seq_model.add(seq_model_seq_sub)
109+
seq_model.add(Dense(2, activation='linear', name='seq_last_dense'))
110+
111+
model = Sequential()
112+
model.add(Dense(10, activation='linear', input_shape=input_shape, name='first_dense'))
113+
model.add(fun_model)
114+
model.add(Dense(6, activation='linear', name='middle_dense'))
115+
model.add(seq_model)
116+
model.add(Dense(4, activation='linear', name='last_dense'))
117+
118+
return model
119+
120+
121+
def randX(batch_size, N):
122+
return np.random.rand(batch_size, N)
123+
124+
125+
@pytest.fixture(scope='module')
126+
def randX_20_15():
127+
return randX(20, 15)
128+
129+
130+
@pytest.mark.parametrize('backend', ['Vivado', 'Quartus'])
131+
@pytest.mark.parametrize('io_type', ['io_parallel', 'io_stream'])
132+
def test_nested_model(randX_20_15, backend, io_type):
133+
n_in = 15
134+
input_shape = (n_in,)
135+
keras_model = make_nested_model(input_shape)
136+
keras_model.compile(optimizer='adam', loss='mae')
137+
138+
config = hls4ml.utils.config_from_keras_model(keras_model, default_precision='fixed<24,12>')
139+
prj_name = f'hls4mlprj_nested_model_{backend}_{io_type}'
140+
output_dir = str(test_root_path / prj_name)
141+
hls_model = hls4ml.converters.convert_from_keras_model(
142+
keras_model, hls_config=config, output_dir=output_dir, io_type=io_type, backend=backend
143+
)
144+
hls_model.compile()
145+
146+
X = randX_20_15
147+
y_keras = keras_model.predict(X)
148+
y_hls4ml = hls_model.predict(X)
149+
150+
np.testing.assert_allclose(y_keras.ravel(), y_hls4ml.ravel(), rtol=1e-2, atol=0.02)
151+
152+
153+
@pytest.mark.parametrize('backend', ['Vivado', 'Quartus'])
154+
@pytest.mark.parametrize('io_type', ['io_parallel', 'io_stream'])
155+
def test_sub_nested_model(randX_20_15, backend, io_type):
156+
n_in = 15
157+
input_shape = (n_in,)
158+
keras_model = make_sub_nested_model(input_shape)
159+
keras_model.compile(optimizer='adam', loss='mae')
160+
161+
config = hls4ml.utils.config_from_keras_model(keras_model, default_precision='fixed<24,12>')
162+
prj_name = f'hls4mlprj_sub_nested_model_{backend}_{io_type}'
163+
output_dir = str(test_root_path / prj_name)
164+
hls_model = hls4ml.converters.convert_from_keras_model(
165+
keras_model, hls_config=config, output_dir=output_dir, io_type=io_type, backend=backend
166+
)
167+
hls_model.compile()
168+
169+
X = randX_20_15
170+
y_keras = keras_model.predict(X)
171+
y_hls4ml = hls_model.predict(X)
172+
173+
np.testing.assert_allclose(y_keras.ravel(), y_hls4ml.ravel(), rtol=1e-2, atol=0.02)

0 commit comments

Comments
 (0)