Repository: keras-team/keras (python)

Issue:
Nested sequentials broken in 3.5
The following reduced example gives an error in Keras 3.5.0 but worked in 3.4.1 and earlier:

```python
import keras

model = keras.Sequential()
model.add(keras.Input(shape=(16,)))
keras.Sequential([model])
```

```
ValueError: Sequential model 'sequential' has no defined input shape yet.
```

The cause is the introduction of the `hasattr` call to check for the `input_shape` property here: https://github.com/keras-team/keras/blob/fa6be07ce86b3f8c5072a3d5ff355b4266baa32d/keras/src/models/sequential.py#L141

Since `hasattr` simply calls `getattr` and checks for an `AttributeError`, this call to `hasattr` actually raises a `ValueError` as though we were trying to call `.input_shape` instead of returning false like was intended.

Pull request:
Replace `ValueError` with `AttributeError` in `Sequential`'s property
Fix #20203

Patch:
diff --git a/keras/src/models/sequential.py b/keras/src/models/sequential.py
index c0ed98fb8..cdf5687a4 100644
--- a/keras/src/models/sequential.py
+++ b/keras/src/models/sequential.py
@@ -268,7 +268,7 @@ class Sequential(Model):
     def input_shape(self):
         if self._functional:
             return self._functional.input_shape
-        raise ValueError(
+        raise AttributeError(
             f"Sequential model '{self.name}' has no defined input shape yet."
         )
 
@@ -276,7 +276,7 @@ class Sequential(Model):
     def output_shape(self):
         if self._functional:
             return self._functional.output_shape
-        raise ValueError(
+        raise AttributeError(
             f"Sequential model '{self.name}' has no defined output shape yet."
         )
 
@@ -284,7 +284,7 @@ class Sequential(Model):
     def inputs(self):
         if self._functional:
             return self._functional.inputs
-        raise ValueError(
+        raise AttributeError(
             f"Sequential model '{self.name}' has no defined inputs yet."
         )
 
@@ -292,7 +292,7 @@ class Sequential(Model):
     def outputs(self):
         if self._functional:
             return self._functional.outputs
-        raise ValueError(
+        raise AttributeError(
             f"Sequential model '{self.name}' has no defined outputs yet."
         )
 
diff --git a/keras/src/trainers/trainer.py b/keras/src/trainers/trainer.py
index 7bf0ba8b7..757907074 100644
--- a/keras/src/trainers/trainer.py
+++ b/keras/src/trainers/trainer.py
@@ -30,6 +30,11 @@ class Trainer:
             "training" in inspect.signature(self.compute_loss).parameters
         )
 
+        # Placeholders used in `compile`
+        self._compile_loss = None
+        self._compile_metrics = None
+        self._loss_tracker = None
+
     @traceback_utils.filter_traceback
     @tracking.no_automatic_dependency_tracking
     def compile(
@@ -155,14 +160,10 @@ class Trainer:
                 loss, loss_weights, output_names=output_names
             )
             self.loss = loss
-        else:
-            self._compile_loss = None
         if metrics is not None or weighted_metrics is not None:
             self._compile_metrics = CompileMetrics(
                 metrics, weighted_metrics, output_names=output_names
             )
-        else:
-            self._compile_metrics = None
         if jit_compile == "auto":
             if run_eagerly:
                 jit_compile = False
@@ -258,7 +259,7 @@ class Trainer:
             if self._compile_loss is not None:
                 metrics.extend(self._compile_loss.metrics)
         metrics.extend(self._metrics)
-        for layer in self._layers:
+        for layer in self._flatten_layers(include_self=False):
             if isinstance(layer, Trainer):
                 # All Trainer-related metrics in sublayers should be ignored
                 # because a new Trainer has been instantiated.
@@ -276,17 +277,13 @@ class Trainer:
 
     def _get_own_metrics(self):
         metrics = []
-        if hasattr(self, "_loss_tracker"):
+        if self._loss_tracker is not None:
             metrics.append(self._loss_tracker)
-        if (
-            hasattr(self, "_compile_metrics")
-            and self._compile_metrics is not None
-        ):
+        if self._compile_metrics is not None:
             metrics.append(self._compile_metrics)
-        if hasattr(self, "_compile_loss") and self._compile_loss is not None:
+        if self._compile_loss is not None:
             metrics.extend(self._compile_loss.metrics)
-        if hasattr(self, "_metrics"):
-            metrics.extend(self._metrics)
+        metrics.extend(self._metrics)
         return metrics
 
     def _clear_previous_trainer_metrics(self):
@@ -300,8 +297,9 @@ class Trainer:
                 layer._tracker.untrack(m)
             layer._loss_tracker = None
             layer._compile_metrics = None
-            layer._compile_loss._metrics = []
-            layer._metrics = []
+            if layer._compile_loss is not None:
+                layer._compile_loss._metrics.clear()
+            layer._metrics.clear()
 
     def compute_loss(
         self,


Test patch:
diff --git a/keras/src/models/sequential_test.py b/keras/src/models/sequential_test.py
index 943f6723b..5e4078042 100644
--- a/keras/src/models/sequential_test.py
+++ b/keras/src/models/sequential_test.py
@@ -227,6 +227,12 @@ class SequentialTest(testing.TestCase):
         self.assertEqual(type(y), list)
         model.summary()
 
+    def test_nested_sequential(self):
+        # https://github.com/keras-team/keras/issues/20203
+        model = Sequential()
+        model.add(Input(shape=(16,)))
+        Sequential([model])
+
     def test_errors(self):
         # Trying to pass 2 Inputs
         model = Sequential()
@@ -359,3 +365,16 @@ class SequentialTest(testing.TestCase):
         layer = Sequential([layers.Dense(4), layers.Dense(8)])
         output_shape = layer.compute_output_shape((1, 2))
         self.assertEqual(output_shape, (1, 8))
+
+    def test_hasattr(self):
+        model = Sequential()
+        self.assertFalse(hasattr(model, "input_shape"))
+        self.assertFalse(hasattr(model, "output_shape"))
+        self.assertFalse(hasattr(model, "inputs"))
+        self.assertFalse(hasattr(model, "outputs"))
+
+        model = Sequential([layers.Input((4,)), layers.Dense(8)])
+        self.assertTrue(hasattr(model, "input_shape"))
+        self.assertTrue(hasattr(model, "output_shape"))
+        self.assertTrue(hasattr(model, "inputs"))
+        self.assertTrue(hasattr(model, "outputs"))
diff --git a/keras/src/trainers/trainer_test.py b/keras/src/trainers/trainer_test.py
index c590436f1..292797c93 100644
--- a/keras/src/trainers/trainer_test.py
+++ b/keras/src/trainers/trainer_test.py
@@ -227,7 +227,6 @@ class TestTrainer(testing.TestCase, parameterized.TestCase):
         )
         self.assertEqual(len(model_weighted.metrics), 3)
 
-    @pytest.mark.requires_trainable_backend
     def test_nested_trainer_metrics(self):
         # https://github.com/keras-team/keras/issues/20188
         model = ExampleModel(units=3)
@@ -253,6 +252,23 @@ class TestTrainer(testing.TestCase, parameterized.TestCase):
         self.assertEqual(new_model.metrics[0], new_model._loss_tracker)
         self.assertEqual(new_model.metrics[1], new_model._compile_metrics)
 
+    def test_nested_trainer_metrics_without_compile(self):
+        model = ExampleModel(units=3)
+        self.assertLen(model.metrics, 0)
+
+        inputs = keras.Input((4,))
+        outputs = model(inputs)
+        outputs = layers.Dense(8)(outputs)
+        new_model = models.Model(inputs, outputs)
+        new_model.compile(
+            optimizer=optimizers.SGD(),
+            loss=losses.MeanSquaredError(),
+            metrics=[metrics.MeanSquaredError()],
+        )
+        self.assertLen(new_model.metrics, 2)
+        self.assertEqual(new_model.metrics[0], new_model._loss_tracker)
+        self.assertEqual(new_model.metrics[1], new_model._compile_metrics)
+
     @pytest.mark.skipif(
         backend.backend() != "torch",
         reason="torch backend runs in eager mode for jit_compile='auto'",