Repository: larq/zookeeper

Issue:
Support nested HParams
If we have a model with multiple independent components, e.g. an encoder and a decoder, or a base network and some auxilliary structure, it would be nice if the `HParams` class could nest other `HParams` classes, one for each of the components.

This would facilitate easy model modularity, e.g. a `build_model` function could internally call `build_encoder(hparams.encoder_hparams)` and `build_decoder(hparams.decoder_hparams)`.

Discussion:

Patch:
diff --git a/zookeeper/hparam.py b/zookeeper/hparam.py
index 709f2ba..5416e0e 100644
--- a/zookeeper/hparam.py
+++ b/zookeeper/hparam.py
@@ -11,14 +11,21 @@ except ImportError:  # pragma: no cover
 
 SPLIT_REGEX = re.compile(r",?(\w+)=")
 
+INDENT = "    "
+
 
 def group(sequence):
     return zip(*[iter(sequence)] * 2)
 
 
-def str_key_val(key, value, color=True):
+def str_key_val(key, value, color=True, single_line=False):
     if callable(value):
         value = "<callable>"
+    if isinstance(value, HParams):
+        if single_line:
+            value = repr(value)
+        else:
+            value = f"\n{INDENT}".join(str(value).split("\n"))
     return f"{BLUE}{key}{RESET}={YELLOW}{value}{RESET}" if color else f"{key}={value}"
 
 
@@ -103,11 +110,16 @@ class HParams(collections.abc.Mapping):
         raise AttributeError("Hyperparameters are immutable, cannot assign to field.")
 
     def __str__(self):
-        params = ",\n    ".join([str_key_val(k, v) for k, v in sorted(self.items())])
-        return f"{self.__class__.__name__}(\n    {params}\n)"
+        params = f",\n{INDENT}".join(
+            [str_key_val(k, v) for k, v in sorted(self.items())]
+        )
+        return f"{self.__class__.__name__}(\n{INDENT}{params}\n)"
 
     def __repr__(self):
         params = ",".join(
-            [str_key_val(k, v, color=False) for k, v in sorted(self.items())]
+            [
+                str_key_val(k, v, color=False, single_line=True)
+                for k, v in sorted(self.items())
+            ]
         )
         return f"{self.__class__.__name__}({params})"


Test patch:
diff --git a/zookeeper/hparam_test.py b/zookeeper/hparam_test.py
index fc97ed8..91e25ea 100644
--- a/zookeeper/hparam_test.py
+++ b/zookeeper/hparam_test.py
@@ -22,6 +22,20 @@ def hyper():
     return Hyper()
 
 
+@pytest.fixture
+def hyper_with_nested():
+    class Child(HParams):
+        c = -1.5
+        d = "aeiou"
+
+    class Parent(HParams):
+        a = 4.9
+        b = "some string"
+        child = Child()
+
+    return Parent()
+
+
 def test_defaults(hyper):
     assert hyper.foo == [1, 2, 3]
     assert hyper.bar == 0.5
@@ -72,6 +86,11 @@ def test_repr(hyper):
     assert repr(hyper) == output
 
 
+def test_repr_nested(hyper_with_nested):
+    output = "Parent(a=4.9,b=some string,child=Child(c=-1.5,d=aeiou))"
+    assert repr(hyper_with_nested) == output
+
+
 def test_str(hyper):
     output = """Hyper(
     bar=0.5,
@@ -81,3 +100,15 @@ def test_str(hyper):
     foo=[1, 2, 3]
 )"""
     assert click.unstyle(str(hyper)) == output
+
+
+def test_str_nested(hyper_with_nested):
+    output = """Parent(
+    a=4.9,
+    b=some string,
+    child=Child(
+        c=-1.5,
+        d=aeiou
+    )
+)"""
+    assert click.unstyle(str(hyper_with_nested)) == output