diff --git a/procrustes/__init__.py b/procrustes/__init__.py
index 317bae8..d45e94e 100644
--- a/procrustes/__init__.py
+++ b/procrustes/__init__.py
@@ -38,3 +38,4 @@
from procrustes.symmetric import *
from procrustes.generic import *
from procrustes.generalized import *
+from procrustes.toeplitz import *
diff --git a/procrustes/test/test_toeplitz.py b/procrustes/test/test_toeplitz.py
new file mode 100644
index 0000000..f034564
--- /dev/null
+++ b/procrustes/test/test_toeplitz.py
@@ -0,0 +1,216 @@
+# -*- coding: utf-8 -*-
+# The Procrustes library provides a set of functions for transforming
+# a matrix to make it as similar as possible to a target matrix.
+#
+# Copyright (C) 2017-2025 The QC-Devs Community
+#
+# This file is part of Procrustes.
+#
+# Procrustes is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# Procrustes is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see
+#
+# --
+r"""Tests for Toeplitz and Hankel Procrustes module."""
+
+import numpy as np
+import pytest
+from numpy.testing import assert_almost_equal
+from scipy.linalg import hankel as scipy_hankel
+from scipy.linalg import toeplitz as scipy_toeplitz
+
+from procrustes.generic import generic
+from procrustes.toeplitz import hankel, toeplitz
+
+# Helper utilities
+
+
+def _is_toeplitz(matrix: np.ndarray, tol: float = 1e-8) -> bool:
+ """Return True if *matrix* is Toeplitz (constant along each diagonal)."""
+ n = matrix.shape[0]
+ for k in range(-(n - 1), n):
+ diag = np.diag(matrix, k)
+ if not np.allclose(diag, diag[0], atol=tol):
+ return False
+ return True
+
+
+def _is_hankel(matrix: np.ndarray, tol: float = 1e-8) -> bool:
+ """Return True if *matrix* is Hankel (constant along each anti-diagonal)."""
+ n = matrix.shape[0]
+ for k in range(2 * n - 1):
+ # collect elements where i + j == k
+ vals = [matrix[i, k - i] for i in range(n) if 0 <= k - i < n]
+ if not np.allclose(vals, vals[0], atol=tol):
+ return False
+ return True
+
+# Toeplitz tests
+
+
+@pytest.mark.parametrize("n", [3, 4, 5, 6])
+def test_toeplitz_exact_recovery(n):
+ r"""When B = A @ T_true with T_true Toeplitz, the solver should recover T_true exactly."""
+ rng = np.random.default_rng(42 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ col = rng.uniform(-3.0, 3.0, n)
+ row = rng.uniform(-3.0, 3.0, n)
+ row[0] = col[0] # Toeplitz constraint: T[0,0] shared
+ t_true = scipy_toeplitz(col, row)
+ mat_b = mat_a @ t_true
+
+ result = toeplitz(mat_a, mat_b)
+
+ assert_almost_equal(result.error, 0.0, decimal=6)
+ assert_almost_equal(result.t, t_true, decimal=6)
+
+
+@pytest.mark.parametrize("n", [3, 4, 5])
+def test_toeplitz_output_is_toeplitz(n):
+ r"""The transformation matrix returned by toeplitz() must be a Toeplitz matrix."""
+ rng = np.random.default_rng(100 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ mat_b = rng.uniform(-5.0, 5.0, (n, n))
+
+ result = toeplitz(mat_a, mat_b)
+
+ assert _is_toeplitz(result.t), "Returned transformation matrix is not Toeplitz."
+
+
+@pytest.mark.parametrize("n", [3, 4, 5])
+def test_toeplitz_error_leq_generic(n):
+ r"""Toeplitz error should be >= generic (unconstrained) error for the same inputs."""
+ rng = np.random.default_rng(200 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ mat_b = rng.uniform(-5.0, 5.0, (n, n))
+
+ res_toeplitz = toeplitz(mat_a, mat_b)
+ res_generic = generic(mat_a, mat_b)
+
+ # Constrained optimum can only be worse than or equal to unconstrained
+ assert res_toeplitz.error >= res_generic.error - 1e-8
+
+
+def test_toeplitz_raises_shape_mismatch():
+ r"""toeplitz() should raise ValueError when shapes cannot be reconciled."""
+ # A is (3,3), B is (3,5) -> after pad=False they differ in columns -> ValueError
+ mat_a = np.random.rand(3, 3)
+ mat_b = np.random.rand(3, 5)
+ with pytest.raises(ValueError):
+ toeplitz(mat_a, mat_b, pad=False)
+
+# Hankel tests
+
+
+@pytest.mark.parametrize("n", [3, 4, 5, 6])
+def test_hankel_exact_recovery(n):
+ r"""When B = A @ H_true with H_true Hankel, the solver should recover H_true exactly."""
+ rng = np.random.default_rng(42 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ col = rng.uniform(-3.0, 3.0, n)
+ row = rng.uniform(-3.0, 3.0, n)
+ row[0] = col[-1] # Hankel constraint: H[n-1,0] shared
+ h_true = scipy_hankel(col, row)
+ mat_b = mat_a @ h_true
+
+ result = hankel(mat_a, mat_b)
+
+ assert_almost_equal(result.error, 0.0, decimal=6)
+ assert_almost_equal(result.t, h_true, decimal=6)
+
+
+@pytest.mark.parametrize("n", [3, 4, 5])
+def test_hankel_output_is_hankel(n):
+ r"""The transformation matrix returned by hankel() must be a Hankel matrix."""
+ rng = np.random.default_rng(400 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ mat_b = rng.uniform(-5.0, 5.0, (n, n))
+
+ result = hankel(mat_a, mat_b)
+
+ assert _is_hankel(result.t), "Returned transformation matrix is not Hankel."
+
+
+@pytest.mark.parametrize("n", [3, 4, 5])
+def test_hankel_error_leq_generic(n):
+ r"""Hankel error should be >= generic (unconstrained) error for the same inputs."""
+ rng = np.random.default_rng(500 + n)
+ mat_a = rng.uniform(-5.0, 5.0, (n, n))
+ mat_b = rng.uniform(-5.0, 5.0, (n, n))
+
+ res_hankel = hankel(mat_a, mat_b)
+ res_generic = generic(mat_a, mat_b)
+
+ assert res_hankel.error >= res_generic.error - 1e-8
+
+
+def test_hankel_raises_shape_mismatch():
+ r"""hankel() should raise ValueError when shapes cannot be reconciled."""
+ mat_a = np.random.rand(3, 3)
+ mat_b = np.random.rand(3, 5)
+ with pytest.raises(ValueError):
+ hankel(mat_a, mat_b, pad=False)
+
+# Rectangular A matrix tests
+
+
+@pytest.mark.parametrize("m, n", [(5, 3), (6, 4), (8, 3)])
+def test_toeplitz_rectangular_output_is_toeplitz(m, n):
+ r"""Toeplitz() must return a Toeplitz T when A is rectangular (m > n)."""
+ rng = np.random.default_rng(300 + m + n)
+ mat_a = rng.uniform(-5.0, 5.0, (m, n))
+ mat_b = rng.uniform(-5.0, 5.0, (m, n))
+
+ result = toeplitz(mat_a, mat_b)
+
+ assert result.t.shape == (n, n), f"Expected T shape ({n}, {n}), got {result.t.shape}"
+ assert _is_toeplitz(result.t), "Returned transformation matrix is not Toeplitz."
+
+
+@pytest.mark.parametrize("m, n", [(5, 3), (6, 4), (8, 3)])
+def test_hankel_rectangular_output_is_hankel(m, n):
+ r"""Hankel() must return a Hankel T when A is rectangular (m > n)."""
+ rng = np.random.default_rng(600 + m + n)
+ mat_a = rng.uniform(-5.0, 5.0, (m, n))
+ mat_b = rng.uniform(-5.0, 5.0, (m, n))
+
+ result = hankel(mat_a, mat_b)
+
+ assert result.t.shape == (n, n), f"Expected T shape ({n}, {n}), got {result.t.shape}"
+ assert _is_hankel(result.t), "Returned transformation matrix is not Hankel."
+
+
+# ProcrustesResult structure tests
+
+
+def test_toeplitz_result_fields():
+ r"""Ensure that the result from toeplitz() has the expected fields."""
+ mat_a = np.random.rand(4, 4)
+ mat_b = np.random.rand(4, 4)
+ result = toeplitz(mat_a, mat_b)
+ assert hasattr(result, "error")
+ assert hasattr(result, "new_a")
+ assert hasattr(result, "new_b")
+ assert hasattr(result, "t")
+ assert result.s is None
+
+
+def test_hankel_result_fields():
+ r"""Ensure that the result from hankel() has the expected fields."""
+ mat_a = np.random.rand(4, 4)
+ mat_b = np.random.rand(4, 4)
+ result = hankel(mat_a, mat_b)
+ assert hasattr(result, "error")
+ assert hasattr(result, "new_a")
+ assert hasattr(result, "new_b")
+ assert hasattr(result, "t")
+ assert result.s is None
diff --git a/procrustes/toeplitz.py b/procrustes/toeplitz.py
new file mode 100644
index 0000000..bcd2b35
--- /dev/null
+++ b/procrustes/toeplitz.py
@@ -0,0 +1,401 @@
+# -*- coding: utf-8 -*-
+# The Procrustes library provides a set of functions for transforming
+# a matrix to make it as similar as possible to a target matrix.
+#
+# Copyright (C) 2017-2025 The QC-Devs Community
+#
+# This file is part of Procrustes.
+#
+# Procrustes is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# Procrustes is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see
+#
+# --
+"""Toeplitz and Hankel Procrustes Module."""
+
+from typing import Optional
+
+import numpy as np
+
+from procrustes.utils import ProcrustesResult, compute_error, setup_input_arrays
+
+__all__ = [
+ "toeplitz",
+ "hankel",
+]
+
+
+def _build_toeplitz_operator(a: np.ndarray) -> np.ndarray:
+ r"""Build the linear operator M such that vec(A @ T) = M @ t for any Toeplitz T.
+
+ A Toeplitz matrix of size n x n is fully determined by its first column c (length n)
+ and first row r (length n), giving 2n-1 unique parameters. We stack them as:
+ t = [c[0], c[1], ..., c[n-1], r[1], r[2], ..., r[n-1]]
+ where c[0] == r[0] (the main diagonal value).
+
+ This function builds M (shape: m*n x (2n-1)) such that:
+ vec(A @ T) = M @ t
+
+ Parameters
+ ----------
+ a : ndarray
+ The 2D-array :math:`\mathbf{A}_{m \times n}`.
+
+ Returns
+ -------
+ op_m : ndarray
+ The operator matrix of shape (m * n, 2n - 1).
+ """
+ m, n = a.shape
+ num_params = 2 * n - 1
+ op_m = np.zeros((m * n, num_params))
+
+ for col_idx in range(n):
+ for row_idx in range(n):
+ # Diagonal index: 0 = main diagonal, >0 = upper, <0 = lower.
+ # Maps (row, col) position in T to its parameter index in the flat vector t.
+ diag = col_idx - row_idx
+ if diag == 0:
+ param_idx = 0 # main diagonal: shared entry t[0]
+ elif diag < 0:
+ param_idx = -diag # sub-diagonal: first column entry t[-diag]
+ else:
+ param_idx = n - 1 + diag # super-diagonal: first row entry t[n-1+diag]
+ op_m[col_idx * m : (col_idx + 1) * m, param_idx] += a[:, row_idx]
+
+ return op_m
+
+
+def _build_hankel_operator(a: np.ndarray) -> np.ndarray:
+ r"""Build the linear operator M such that vec(A @ H) = M @ h for any Hankel H.
+
+ A Hankel matrix of size n x n is fully determined by its first column c (length n)
+ and last row r (length n), giving 2n-1 unique parameters. We stack them as:
+ h = [c[0], c[1], ..., c[n-1], r[1], r[2], ..., r[n-1]]
+ where c[n-1] == r[0] (the anti-diagonal corner value).
+
+ In a Hankel matrix: H[i, j] = h[i + j] (using 0-based indexing into the parameter vector).
+
+ Parameters
+ ----------
+ a : ndarray
+ The 2D-array :math:`\mathbf{A}_{m \times n}`.
+
+ Returns
+ -------
+ op_m : ndarray
+ The operator matrix of shape (m * n, 2n - 1).
+ """
+ m, n = a.shape
+ num_params = 2 * n - 1
+ op_m = np.zeros((m * n, num_params))
+
+ for col_idx in range(n):
+ for row_idx in range(n):
+ # H[row_idx, col_idx] = h[row_idx + col_idx] by the Hankel anti-diagonal property.
+ param_idx = row_idx + col_idx
+ op_m[col_idx * m : (col_idx + 1) * m, param_idx] += a[:, row_idx]
+
+ return op_m
+
+
+def _params_to_toeplitz(params: np.ndarray, n: int) -> np.ndarray:
+ r"""Construct an n x n Toeplitz matrix from its parameter vector.
+
+ Parameters
+ ----------
+ params : ndarray
+ 1D array of length 2n-1. The first n entries are the first column
+ (top to bottom), and entries n..2n-2 are the rest of the first row
+ (left to right, excluding the corner which is params[0]).
+ n : int
+ Size of the square Toeplitz matrix.
+
+ Returns
+ -------
+ t_mat : ndarray
+ The n x n Toeplitz matrix.
+ """
+ t_mat = np.zeros((n, n))
+ for i in range(n):
+ for j in range(n):
+ # Same diagonal mapping as in _build_toeplitz_operator.
+ diag = j - i
+ if diag == 0:
+ t_mat[i, j] = params[0]
+ elif diag < 0:
+ t_mat[i, j] = params[-diag]
+ else:
+ t_mat[i, j] = params[n - 1 + diag]
+ return t_mat
+
+
+def _params_to_hankel(params: np.ndarray, n: int) -> np.ndarray:
+ r"""Construct an n x n Hankel matrix from its parameter vector.
+
+ Parameters
+ ----------
+ params : ndarray
+ 1D array of length 2n-1. H[i, j] = params[i + j].
+ n : int
+ Size of the square Hankel matrix.
+
+ Returns
+ -------
+ h_mat : ndarray
+ The n x n Hankel matrix.
+ """
+ h_mat = np.zeros((n, n))
+ for i in range(n):
+ for j in range(n):
+ h_mat[i, j] = params[i + j]
+ return h_mat
+
+
+def toeplitz(
+ a: np.ndarray,
+ b: np.ndarray,
+ pad: bool = True,
+ translate: bool = False,
+ scale: bool = False,
+ unpad_col: bool = False,
+ unpad_row: bool = False,
+ check_finite: bool = True,
+ weight: Optional[np.ndarray] = None,
+) -> ProcrustesResult:
+ r"""Perform one-sided Toeplitz Procrustes.
+
+ Given a matrix :math:`\mathbf{A}_{m \times n}` and a reference matrix :math:`\mathbf{B}_{m
+ \times n}`, find the Toeplitz transformation matrix :math:`\mathbf{T}_{n \times n}` that
+ makes :math:`\mathbf{A}\mathbf{T}` as close as possible to :math:`\mathbf{B}`. In other words,
+
+ .. math::
+ \underbrace{\min}_{\mathbf{T} \in \mathcal{T}_n} \|\mathbf{A}\mathbf{T} - \mathbf{B}\|_{F}^2
+
+ where :math:`\mathcal{T}_n` denotes the set of all :math:`n \times n` Toeplitz matrices.
+
+ A Toeplitz matrix has constant values along each diagonal:
+
+ .. math::
+ T_{ij} = t_{j-i}
+
+ so it is fully determined by :math:`2n-1` parameters.
+
+ Parameters
+ ----------
+ a : ndarray
+ The 2D-array :math:`\mathbf{A}` which is going to be transformed.
+ b : ndarray
+ The 2D-array :math:`\mathbf{B}` representing the reference matrix.
+ pad : bool, optional
+ Add zero rows (at the bottom) and/or columns (to the right-hand side) of matrices
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` so that they have the same shape.
+ translate : bool, optional
+ If True, both arrays are centered at origin (columns of the arrays will have mean zero).
+ scale : bool, optional
+ If True, both arrays are normalized with respect to the Frobenius norm, i.e.,
+ :math:`\text{Tr}\left[\mathbf{A}^\dagger\mathbf{A}\right] = 1` and
+ :math:`\text{Tr}\left[\mathbf{B}^\dagger\mathbf{B}\right] = 1`.
+ unpad_col : bool, optional
+ If True, zero columns (with values less than 1.0e-8) on the right-hand side of the initial
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` matrices are removed.
+ unpad_row : bool, optional
+ If True, zero rows (with values less than 1.0e-8) at the bottom of the initial
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` matrices are removed.
+ check_finite : bool, optional
+ If True, convert the input to an array, checking for NaNs or Infs.
+ weight : ndarray, optional
+ The 1D-array representing the weights of each row of :math:`\mathbf{A}`. This defines the
+ elements of the diagonal matrix :math:`\mathbf{W}` that is multiplied by :math:`\mathbf{A}`
+ matrix, i.e., :math:`\mathbf{A} \rightarrow \mathbf{WA}`.
+
+ Returns
+ -------
+ res : ProcrustesResult
+ The Procrustes result represented as a :class:`utils.ProcrustesResult` object.
+
+ Notes
+ -----
+ The optimal Toeplitz matrix is found by reformulating the problem as a linear least-squares
+ problem. Since a Toeplitz matrix :math:`\mathbf{T}` of size :math:`n \times n` is determined
+ by :math:`2n-1` parameters (its first column and first row), we can write:
+
+ .. math::
+ \text{vec}(\mathbf{A}\mathbf{T}) = \mathbf{M} \mathbf{t}
+
+ where :math:`\mathbf{t}` is the parameter vector and :math:`\mathbf{M}` is a structured matrix
+ derived from :math:`\mathbf{A}`. The optimal :math:`\mathbf{t}` is then found by solving the
+ normal equations:
+
+ .. math::
+ \mathbf{t}^{\text{opt}} = \arg\min_{\mathbf{t}}
+ \|\mathbf{M}\mathbf{t} - \text{vec}(\mathbf{B})\|_2^2
+
+ References
+ ----------
+ .. [1] Yang, J., & Deng, Y. (2013). "Procrustes Problems for General, Triangular, and
+ Symmetric Toeplitz Matrices."
+ Journal of Applied Mathematics, 2013.
+ http://dx.doi.org/10.1155/2013/696019
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from scipy.linalg import toeplitz as scipy_toeplitz
+ >>> from procrustes import toeplitz
+ >>> n = 4
+ >>> A = np.random.rand(n, n)
+ >>> T_true = scipy_toeplitz([1, 2, 3, 4], [1, 0.5, 0.25, 0.1])
+ >>> B = A @ T_true
+ >>> result = toeplitz(A, B)
+ >>> print(result.error) # should be near zero
+ >>> print(result.t) # should match T_true
+
+ """
+ new_a, new_b = setup_input_arrays(
+ a, b, unpad_col, unpad_row, pad, translate, scale, check_finite, weight
+ )
+ if new_a.shape != new_b.shape:
+ raise ValueError(
+ f"Shape of A and B does not match: {new_a.shape} != {new_b.shape} "
+ "Check pad, unpad_col, and unpad_row arguments."
+ )
+
+ _, n = new_a.shape
+ # Build operator M so that vec(A @ T) = M @ t, then solve for t via least-squares.
+ op_m = _build_toeplitz_operator(new_a)
+ b_vec = new_b.flatten(order="F") # column-major vectorization to match vec convention
+ params, _, _, _ = np.linalg.lstsq(op_m, b_vec, rcond=None)
+ t_opt = _params_to_toeplitz(params, n)
+ error = compute_error(new_a, new_b, t_opt)
+
+ return ProcrustesResult(error=error, new_a=new_a, new_b=new_b, t=t_opt, s=None)
+
+
+def hankel(
+ a: np.ndarray,
+ b: np.ndarray,
+ pad: bool = True,
+ translate: bool = False,
+ scale: bool = False,
+ unpad_col: bool = False,
+ unpad_row: bool = False,
+ check_finite: bool = True,
+ weight: Optional[np.ndarray] = None,
+) -> ProcrustesResult:
+ r"""Perform one-sided Hankel Procrustes.
+
+ Given a matrix :math:`\mathbf{A}_{m \times n}` and a reference matrix :math:`\mathbf{B}_{m
+ \times n}`, find the Hankel transformation matrix :math:`\mathbf{H}_{n \times n}` that
+ makes :math:`\mathbf{A}\mathbf{H}` as close as possible to :math:`\mathbf{B}`. In other words,
+
+ .. math::
+ \underbrace{\min}_{\mathbf{H} \in \mathcal{H}_n} \|\mathbf{A}\mathbf{H} - \mathbf{B}\|_{F}^2
+
+ where :math:`\mathcal{H}_n` denotes the set of all :math:`n \times n` Hankel matrices.
+
+ A Hankel matrix has constant values along each anti-diagonal:
+
+ .. math::
+ H_{ij} = h_{i+j}
+
+ so it is fully determined by :math:`2n-1` parameters.
+
+ Parameters
+ ----------
+ a : ndarray
+ The 2D-array :math:`\mathbf{A}` which is going to be transformed.
+ b : ndarray
+ The 2D-array :math:`\mathbf{B}` representing the reference matrix.
+ pad : bool, optional
+ Add zero rows (at the bottom) and/or columns (to the right-hand side) of matrices
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` so that they have the same shape.
+ translate : bool, optional
+ If True, both arrays are centered at origin (columns of the arrays will have mean zero).
+ scale : bool, optional
+ If True, both arrays are normalized with respect to the Frobenius norm, i.e.,
+ :math:`\text{Tr}\left[\mathbf{A}^\dagger\mathbf{A}\right] = 1` and
+ :math:`\text{Tr}\left[\mathbf{B}^\dagger\mathbf{B}\right] = 1`.
+ unpad_col : bool, optional
+ If True, zero columns (with values less than 1.0e-8) on the right-hand side of the initial
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` matrices are removed.
+ unpad_row : bool, optional
+ If True, zero rows (with values less than 1.0e-8) at the bottom of the initial
+ :math:`\mathbf{A}` and :math:`\mathbf{B}` matrices are removed.
+ check_finite : bool, optional
+ If True, convert the input to an array, checking for NaNs or Infs.
+ weight : ndarray, optional
+ The 1D-array representing the weights of each row of :math:`\mathbf{A}`. This defines the
+ elements of the diagonal matrix :math:`\mathbf{W}` that is multiplied by :math:`\mathbf{A}`
+ matrix, i.e., :math:`\mathbf{A} \rightarrow \mathbf{WA}`.
+
+ Returns
+ -------
+ res : ProcrustesResult
+ The Procrustes result represented as a :class:`utils.ProcrustesResult` object.
+
+ Notes
+ -----
+ The optimal Hankel matrix is found by reformulating the problem as a linear least-squares
+ problem. Since a Hankel matrix :math:`\mathbf{H}` of size :math:`n \times n` is determined
+ by :math:`2n-1` parameters, we can write:
+
+ .. math::
+ \text{vec}(\mathbf{A}\mathbf{H}) = \mathbf{M} \mathbf{h}
+
+ where :math:`\mathbf{h}` is the parameter vector and :math:`\mathbf{M}` is a structured matrix
+ derived from :math:`\mathbf{A}`. The optimal :math:`\mathbf{h}` is then found by solving the
+ normal equations:
+
+ .. math::
+ \mathbf{h}^{\text{opt}} = \arg\min_{\mathbf{h}}
+ \|\mathbf{M}\mathbf{h} - \text{vec}(\mathbf{B})\|_2^2
+
+ References
+ ----------
+ .. [1] Yang, J., & Deng, Y. (2013). "Procrustes Problems for General, Triangular, and
+ Symmetric Toeplitz Matrices."
+ Journal of Applied Mathematics, 2013.
+ http://dx.doi.org/10.1155/2013/696019
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> from scipy.linalg import hankel as scipy_hankel
+ >>> from procrustes import hankel
+ >>> n = 4
+ >>> A = np.random.rand(n, n)
+ >>> H_true = scipy_hankel([1, 2, 3, 4], [4, 5, 6, 7])
+ >>> B = A @ H_true
+ >>> result = hankel(A, B)
+ >>> print(result.error) # should be near zero
+ >>> print(result.t) # should match H_true
+
+ """
+ new_a, new_b = setup_input_arrays(
+ a, b, unpad_col, unpad_row, pad, translate, scale, check_finite, weight
+ )
+ if new_a.shape != new_b.shape:
+ raise ValueError(
+ f"Shape of A and B does not match: {new_a.shape} != {new_b.shape} "
+ "Check pad, unpad_col, and unpad_row arguments."
+ )
+
+ _, n = new_a.shape
+ # Build operator M so that vec(A @ H) = M @ h, then solve for h via least-squares.
+ op_m = _build_hankel_operator(new_a)
+ b_vec = new_b.flatten(order="F") # column-major vectorization to match vec convention
+ params, _, _, _ = np.linalg.lstsq(op_m, b_vec, rcond=None)
+ t_opt = _params_to_hankel(params, n)
+ error = compute_error(new_a, new_b, t_opt)
+
+ return ProcrustesResult(error=error, new_a=new_a, new_b=new_b, t=t_opt, s=None)