Skip to content

Commit 9fd874b

Browse files
committed
Code reviews
1 parent 9108fdc commit 9fd874b

File tree

4 files changed

+40
-56
lines changed

4 files changed

+40
-56
lines changed

docs/source/user/start.rst

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,8 @@ in the :doc:`../auto_examples/index` section.
159159
Variable shapes: For tabular models (scikit-learn, tree ensembles, dense
160160
neural nets), inputs are typically 2D MVars with shape ``(batch, features)``
161161
and outputs are 1D or 2D (the package orients a 1D output based on the
162-
batch size). For convolutional neural networks (Keras/PyTorch), inputs can be
163-
4D MVars with shape ``(batch, H, W, C)`` (channels-last). A 3D input of shape
164-
``(H, W, C)`` is automatically interpreted as a single-batch input.
162+
batch size). For convolutional neural networks (Keras/PyTorch), use 4D MVars
163+
with shape ``(batch, H, W, C)`` (channels-last).
165164

166165

167166
.. rubric:: Footnotes

src/gurobi_ml/modeling/_var_utils.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -308,15 +308,12 @@ def validate_input_vars(model, gp_vars, accepted_dim=(1, 2)):
308308
return (mv.reshape(1, -1), None, None)
309309
if mv.ndim in accepted_dim:
310310
return (mv, None, None)
311-
# Try to add a leading batch dimension if that makes it valid
312-
if (mv.ndim + 1) in accepted_dim:
313-
if mv.ndim == 1:
314-
return (mv.reshape(1, -1), None, None)
315-
if mv.ndim == 3:
316-
return (mv.reshape((1,) + mv.shape), None, None)
311+
# Only allow legacy 1D -> 2D promotion; do not auto-batch 3D to 4D.
312+
if (mv.ndim + 1) in accepted_dim and mv.ndim == 1:
313+
return (mv.reshape(1, -1), None, None)
317314
raise ParameterError(
318-
"Variables should be an MVar of dimension {} and is dimension {}".format(
319-
" or ".join([f"{d}" for d in accepted_dim]), mv.ndim
315+
"Variables should be an MVar of dimension {}".format(
316+
" or ".join([f"{d}" for d in accepted_dim])
320317
)
321318
)
322319

@@ -349,11 +346,9 @@ def validate_input_vars(model, gp_vars, accepted_dim=(1, 2)):
349346
return (mv.reshape(1, -1), None, None)
350347
if mv.ndim in accepted_dim:
351348
return (mv, None, None)
352-
if (mv.ndim + 1) in accepted_dim:
353-
if mv.ndim == 1:
354-
return (mv.reshape(1, -1), None, None)
355-
if mv.ndim == 3:
356-
return (mv.reshape((1,) + mv.shape), None, None)
349+
# Only allow legacy 1D -> 2D promotion; do not auto-batch 3D to 4D.
350+
if (mv.ndim + 1) in accepted_dim and mv.ndim == 1:
351+
return (mv.reshape(1, -1), None, None)
357352
raise ParameterError(
358353
"Input variables have dimension {} but expected {}".format(
359354
mv.ndim, ", ".join(map(str, accepted_dim))

src/gurobi_ml/modeling/neuralnet/layers.py

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,6 @@ def _create_output_vars(self, input_vars):
227227
int(output_shape_1),
228228
self.channels,
229229
)
230-
print(
231-
f"Conv2D layer with input shape {input_vars.shape} gives output shape {output_shape}"
232-
)
233-
print(
234-
f" kernel size {self.kernel_size}, stride {self.strides}, padding {self.padding}"
235-
)
236230
rval = self.gp_model.addMVar(output_shape, lb=-gp.GRB.INFINITY, name="act")
237231
self.gp_model.update()
238232
return rval
@@ -242,7 +236,8 @@ def _mip_model(self, **kwargs):
242236
model = self.gp_model
243237
model.update()
244238

245-
(_, height, width, _) = self.input.shape
239+
(_, height, width, in_c) = self.input.shape
240+
out_n, out_h, out_w, out_c = self.output.shape
246241
mixing = self.gp_model.addMVar(
247242
self.output.shape,
248243
lb=-gp.GRB.INFINITY,
@@ -254,25 +249,24 @@ def _mip_model(self, **kwargs):
254249

255250
assert self.padding == "valid"
256251

257-
# Here comes the complicated loop...
258-
# I am sure there is a better way but this is a pedestrian version
259-
kernel_w, kernel_h = self.kernel_size
260-
stride_h, stride_w = self.strides
261-
for k in range(self.channels):
262-
for out_i, i in enumerate(range(0, height - kernel_h + 1, stride_h)):
263-
if i + kernel_h > height:
252+
kh, kw = self.kernel_size
253+
sh, sw = self.strides
254+
# Pre-flatten kernel to (kh*kw*in_c, out_c) for efficient batched matmul
255+
coefs_flat = self.coefs.reshape(int(kh * kw * in_c), int(out_c))
256+
257+
for oi in range(int(out_h)):
258+
i = oi * sh
259+
if i + kh > height:
260+
continue
261+
for oj in range(int(out_w)):
262+
j = oj * sw
263+
if j + kw > width:
264264
continue
265-
for out_j, j in enumerate(range(0, width - kernel_w + 1, stride_w)):
266-
if j + kernel_w > width:
267-
continue
268-
self.gp_model.addConstr(
269-
mixing[:, out_i, out_j, k]
270-
== (
271-
self.input[:, i : i + kernel_h, j : j + kernel_w, :]
272-
* self.coefs[:, :, :, k]
273-
).sum()
274-
+ self.intercept[k]
275-
)
265+
# Extract patch (batch, kh, kw, in_c) and flatten to (batch, kh*kw*in_c)
266+
patch = self.input[:, i : i + kh, j : j + kw, :]
267+
patch2d = patch.reshape(int(out_n), int(kh * kw * in_c))
268+
expr = patch2d @ coefs_flat + self.intercept
269+
self.gp_model.addConstr(mixing[:, oi, oj, :] == expr)
276270

277271
if "activation" in kwargs:
278272
activation = kwargs["activation"]
@@ -313,7 +307,6 @@ def __init__(self, gp_model, output_vars, input_vars, **kwargs):
313307
def _create_output_vars(self, input_vars):
314308
assert len(input_vars.shape) >= 2
315309
output_shape = (input_vars.shape[0], int(np.prod(input_vars.shape[1:])))
316-
print(f"Flattening {input_vars.shape} into {output_shape}")
317310
rval = self.gp_model.addMVar(output_shape, lb=-gp.GRB.INFINITY, name="act")
318311
self.gp_model.update()
319312
return rval
@@ -370,9 +363,6 @@ def _create_output_vars(self, input_vars):
370363
)
371364
rval = self.gp_model.addMVar(output_shape, lb=-gp.GRB.INFINITY, name="act")
372365
self.gp_model.update()
373-
print(
374-
f"MaxPool2D layer with input shape {input_vars.shape} gives output shape {output_shape}"
375-
)
376366
return rval
377367

378368
def _mip_model(self, **kwargs):

src/gurobi_ml/torch/sequential.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ def __init__(self, gp_model, predictor, input_vars, output_vars=None, **kwargs):
121121
if isinstance(step, nn.Softmax):
122122
raise NoModel(predictor, "Softmax activation is not supported")
123123
raise NoModel(predictor, f"Unsupported layer {type(step).__name__}")
124-
# Accept both tabular (1D/2D) and spatial (3D/4D NHWC) inputs at the top level.
125-
# validate_input_vars will add a batch dimension for 1D/3D inputs as needed.
124+
# Accept tabular (1D/2D) and spatial (4D NHWC) inputs at the top level.
125+
# 3D inputs are not auto-batched; users should pass 4D for CNNs.
126126
super().__init__(
127-
gp_model, predictor, input_vars, output_vars, accepted_dim=(1, 2, 3, 4)
127+
gp_model, predictor, input_vars, output_vars, accepted_dim=(1, 2, 4)
128128
)
129129

130130
def _mip_model(self, **kwargs):
@@ -169,14 +169,14 @@ def _mip_model(self, **kwargs):
169169
)
170170
N = H * W * C
171171
if layer_weight.shape[0] == N:
172-
pt_index_for_mip = [0] * N
173-
for h in range(H):
174-
for w in range(W):
175-
for c in range(C):
176-
k_mip = h * (W * C) + w * C + c
177-
j_pt = c * (H * W) + h * W + w
178-
pt_index_for_mip[k_mip] = j_pt
179-
layer_weight = layer_weight[np.array(pt_index_for_mip), :]
172+
# Build a vectorized mapping from MIP NHWC row-major order
173+
# (h,w,c) to PyTorch's NCHW flatten order (c,h,w).
174+
# idx has shape (C,H,W) with values 0..N-1 in PyTorch order.
175+
idx = np.arange(N).reshape(C, H, W)
176+
# Transpose to (H,W,C) and ravel to get, for each MIP row k,
177+
# the corresponding PyTorch row j.
178+
pt_index_for_mip = idx.transpose(1, 2, 0).ravel()
179+
layer_weight = layer_weight[pt_index_for_mip, :]
180180
pre_flat_spatial_shape = None
181181
layer = self._add_dense_layer(
182182
_input,

0 commit comments

Comments
 (0)