Repository: GPflow/GPflow

Issue:
ChangePoints kernel breaks for len(X) != len(X2)
<!-- Lines like this are comments and will be invisible -->

# Bug 
There seems to be a bug for models using the Changepoints kernel, whereby given a model 
m =gpflow.models.GPR(data=(X,y), kernel=k, mean_function=None)
with k a Changepoint kernel, (using a combination of any base kernels), the model won't produce a value for predict_y. 

<!-- A clear and concise description of what the bug is. -->

## To reproduce

**Minimal, reproducible example**
<!-- We need to be able to reproduce the bug by simply copy and pasting your code -->
```python
import numpy as np
import gpflow
X = np.linspace(0,100,100).reshape(100,1)
base_k1 = gpflow.kernels.Matern32(lengthscales=0.2)
base_k2 = gpflow.kernels.Matern32(lengthscales=2.0)
k = gpflow.kernels.ChangePoints([base_k1, base_k2], [0.0], steepness=5.0)
k(X)  # works

N = 25  # anything other than N=100 will reproduce the bug
xx = np.linspace(0,50,N).reshape(N,1)
k(X, xx)  # breaks
```

**Stack trace, or error message**
```
---------------------------------------------------------------------------
InvalidArgumentError                      Traceback (most recent call last)
<ipython-input-25-d1dbc7941bae> in <module>
----> 1 k(X, xx)

~/Code/GPflow/gpflow/kernels/base.py in __call__(self, X, X2, full_cov, presliced)
    170 
    171         else:
--> 172             return self.K(X, X2)
    173 
    174     def __add__(self, other):

~/Code/GPflow/gpflow/kernels/changepoints.py in K(self, X, X2)
     83         N = tf.shape(X)[0]
     84         ones = tf.ones((N, N, 1), dtype=X.dtype)
---> 85         starters = tf.concat([ones, starters], axis=2)
     86         stoppers = tf.concat([stoppers, ones], axis=2)
     87 

~/anaconda3/envs/gpflux2/lib/python3.7/site-packages/tensorflow_core/python/util/dispatch.py in wrapper(*args, **kwargs)
    178     """Call target, and fall back on dispatchers if there is a TypeError."""
    179     try:
--> 180       return target(*args, **kwargs)
    181     except (TypeError, ValueError):
    182       # Note: convert_to_eager_tensor currently raises a ValueError, not a

~/anaconda3/envs/gpflux2/lib/python3.7/site-packages/tensorflow_core/python/ops/array_ops.py in concat(values, axis, name)
   1515           dtype=dtypes.int32).get_shape().assert_has_rank(0)
   1516       return identity(values[0], name=name)
-> 1517   return gen_array_ops.concat_v2(values=values, axis=axis, name=name)
   1518 
   1519 

~/anaconda3/envs/gpflux2/lib/python3.7/site-packages/tensorflow_core/python/ops/gen_array_ops.py in concat_v2(values, axis, name)
   1116         pass  # Add nodes to the TensorFlow graph.
   1117     except _core._NotOkStatusException as e:
-> 1118       _ops.raise_from_not_ok_status(e, name)
   1119   # Add nodes to the TensorFlow graph.
   1120   if not isinstance(values, (list, tuple)):

~/anaconda3/envs/gpflux2/lib/python3.7/site-packages/tensorflow_core/python/framework/ops.py in raise_from_not_ok_status(e, name)
   6604   message = e.message + (" name: " + name if name is not None else "")
   6605   # pylint: disable=protected-access
-> 6606   six.raise_from(core._status_to_exception(e.code, message), None)
   6607   # pylint: enable=protected-access
   6608 

~/anaconda3/envs/gpflux2/lib/python3.7/site-packages/six.py in raise_from(value, from_value)

InvalidArgumentError: ConcatOp : Dimensions of inputs should match: shape[0] = [100,100,1] vs. shape[1] = [100,25,1] [Op:ConcatV2] name: concat
```

## Expected behavior

Should return the kernel matrix of shape (100, 25).

## System information

* GPflow version: 2.0.1 (reproduced on `develop`)
* GPflow installed from: 'pip install gpflow'
* TensorFlow version: 2.1.0
* Python version  3.7.5
* Operating system Ubuntu Linux (18.04.3)

## Additional context

For me changing these two lines in changepoints.py or kernels did the trick: 

```python
    def K(self, X: tf.Tensor, X2: Optional[tf.Tensor] = None) -> tf.Tensor:
        sig_X = self._sigmoids(X)  # N x 1 x Ncp
        sig_X2 = self._sigmoids(X2) if X2 is not None else sig_X

        # `starters` are the sigmoids going from 0 -> 1, whilst `stoppers` go
        # from 1 -> 0, dimensions are N x N x Ncp
        starters = sig_X * tf.transpose(sig_X2, perm=(1, 0, 2))
        stoppers = (1 - sig_X) * tf.transpose((1 - sig_X2), perm=(1, 0, 2))

        # prepend `starters` with ones and append ones to `stoppers` since the
        # first kernel has no start and the last kernel has no end
        N = tf.shape(X)[0]
        M = tf.shape(X2)[0] if X2 is not None else N # THIS IS THE FIX
        ones = tf.ones((N, M, 1), dtype=X.dtype) #PREVIOUSLY N WAS IN PLACE OF M HERE
        starters = tf.concat([ones, starters], axis=2)
        stoppers = tf.concat([stoppers, ones], axis=2)
```

However I have not run any kind of tests to make sure this would not break anything else (neither have I added a unit test for the changepoint kernel). It could be that I am using the changepoint kernel incorrectly, in which case it would be very helpful if a short demo of using the kernel in a model could be appended to the changepoint kernel notebook :)



Discussion:
Thanks @WillJKBoulton for the bugreport! This is indeed a bug in the Changepoint kernel itself; I'll edit your minimal example to make it even smaller and highlight that fact!
And thanks for already investigating what a fix might look like :) we'll confirm whether that doesn't break anything else & add some tests - hopefully we'll get the bugfix out sometime in the next couple of days!
Thanks a lot, that is very quick! 

Resolving patch:
diff --git a/gpflow/kernels/changepoints.py b/gpflow/kernels/changepoints.py
--- a/gpflow/kernels/changepoints.py
+++ b/gpflow/kernels/changepoints.py
@@ -70,18 +70,19 @@ def _set_kernels(self, kernels: List[Kernel]):
         self.kernels = kernels
 
     def K(self, X: tf.Tensor, X2: Optional[tf.Tensor] = None) -> tf.Tensor:
-        sig_X = self._sigmoids(X)  # N x 1 x Ncp
-        sig_X2 = self._sigmoids(X2) if X2 is not None else sig_X
+        sig_X = self._sigmoids(X)  # N1 x 1 x Ncp
+        sig_X2 = self._sigmoids(X2) if X2 is not None else sig_X  # N2 x 1 x Ncp
 
         # `starters` are the sigmoids going from 0 -> 1, whilst `stoppers` go
-        # from 1 -> 0, dimensions are N x N x Ncp
+        # from 1 -> 0, dimensions are N1 x N2 x Ncp
         starters = sig_X * tf.transpose(sig_X2, perm=(1, 0, 2))
         stoppers = (1 - sig_X) * tf.transpose((1 - sig_X2), perm=(1, 0, 2))
 
         # prepend `starters` with ones and append ones to `stoppers` since the
         # first kernel has no start and the last kernel has no end
-        N = tf.shape(X)[0]
-        ones = tf.ones((N, N, 1), dtype=X.dtype)
+        N1 = tf.shape(X)[0]
+        N2 = tf.shape(X2)[0] if X2 is not None else N1
+        ones = tf.ones((N1, N2, 1), dtype=X.dtype)
         starters = tf.concat([ones, starters], axis=2)
         stoppers = tf.concat([stoppers, ones], axis=2)
 


Test patch:
diff --git a/tests/gpflow/kernels/test_changepoints.py b/tests/gpflow/kernels/test_changepoints.py
new file mode 100644
--- /dev/null
+++ b/tests/gpflow/kernels/test_changepoints.py
@@ -0,0 +1,17 @@
+import numpy as np
+import gpflow
+
+
+def test_changepoint_with_X1_X2():
+    N = 100
+    X = np.linspace(0, 100, N).reshape(N, 1)
+    base_k1 = gpflow.kernels.Matern32(lengthscales=0.2)
+    base_k2 = gpflow.kernels.Matern32(lengthscales=2.0)
+    k = gpflow.kernels.ChangePoints([base_k1, base_k2], [0.0], steepness=5.0)
+    K = k(X)
+    assert K.shape == [N, N]
+
+    N2 = 25
+    X2 = np.linspace(0, 50, N2).reshape(N2, 1)
+    K = k(X, X2)
+    assert K.shape == [N, N2]