# -*- 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-2024 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 <http://www.gnu.org/licenses/>
#
# --
"""K-opt (Greedy) Heuristic Module."""
import itertools as it
from typing import Callable, Tuple
import numpy as np
__all__ = [
"kopt_heuristic_single",
"kopt_heuristic_double",
]
[docs]
def kopt_heuristic_single(
fun: Callable, p0: np.ndarray, k: int = 3, tol: float = 1.0e-8
) -> Tuple[np.ndarray, float]:
r"""Find a locally-optimal permutation matrix using the k-opt (greedy) heuristic.
.. math::
\underbrace{\text{min}}_{\left\{\mathbf{P} \left| {[\mathbf{P}]_{ij} \in \{0, 1\}
\atop \sum_{i=1}^n [\mathbf{P}]_{ij} = \sum_{j=1}^n [\mathbf{P}]_{ij} = 1} \right. \right\}}
f(\mathbf{P})
All possible 2-, ..., k-fold column-permutations of the initial permutation matrix are tried to
identify one which gives a lower value of objective function :math:`f`.
Starting from this updated permutation matrix, the process is repeated until no further k-fold
column-reordering of a given permutation matrix lower the objective function.
Parameters
----------
fun : callable
The objective function :math:`f` to be minimized.
p0 : ndarray
The 2D-array permutation matrix representing the initial guess for :math:`\mathbf{P}`.
k : int, optional
The order of the permutation. For example, `k=3` swaps all possible 3-permutations.
tol : float, optional
When value of the objective function is less than given tolerance, the algorithm stops.
Returns
-------
p_opt : ndarray
The locally-optimal permutation matrix :math:`\mathbf{P}` (i.e., solution).
f_opt : float
The locally-optimal value of objective function given by :math:`\text{fun(p_opt)}`.
"""
# pylint: disable=too-many-nested-blocks
# check whether p0 is a valid permutation matrix
if p0.ndim != 2 or p0.shape[0] != p0.shape[1]:
raise ValueError(f"Argument p0 should be a square array. Given p0 shape={p0.shape}")
if not np.all(np.logical_or(p0 == 0, p0 == 1)):
raise ValueError("Elements of permutation matrix p0 can only be 0 or 1.")
if np.all(np.sum(p0, axis=0) != 1) or np.all(np.sum(p0, axis=1) != 1):
raise ValueError("Sum over rows or columns of p0 matrix isn't equal 1.")
# check k
if k < 2 or not isinstance(k, (int, np.integer)):
raise ValueError(f"Argument k={k} must be a integer greater than 1. Given type {type(k)}")
if k > p0.shape[0]:
raise ValueError(f"Argument k={k} is not smaller than {p0.shape[0]} (number of p0 rows).")
# compute initial value of the objective function & assign best P matrix
f_opt = fun(p0)
p_opt = np.copy(p0)
# swap rows and columns until the permutation matrix is not improved
search = True
while search:
search = False
# make sure p0 guess is the best permutation matrix found thus far
p0 = np.copy(p_opt)
for perm in it.permutations(np.arange(p0.shape[0]), r=int(k)):
comb = tuple(sorted(perm))
if perm != comb:
# row-swap P matrix & compute objective function
perm_p = np.copy(p0)
perm_p[:, comb] = perm_p[:, perm]
# compute objective function for permuted P matrix & compare
perm_f = fun(perm_p)
if perm_f < f_opt:
p_opt, f_opt = perm_p, perm_f
# set search=True to keep permuting the new p_opt unless this is already an
# exhaustive search (i.e., k equals number of rows of p matrix)
search = bool(k < p0.shape[0])
# check whether perfect permutation matrix is found
# TODO: smarter threshold based on norm of matrix
if f_opt <= tol:
return p_opt, f_opt
return p_opt, f_opt
[docs]
def kopt_heuristic_double(
fun: Callable, p1: np.ndarray, p2: np.ndarray, k: int = 3, tol: float = 1.0e-8
) -> Tuple[np.ndarray, np.ndarray, float]:
r"""Find locally-optimal permutation matrices using the k-opt (greedy) heuristic.
.. math::
\underbrace{\text{arg min}}_{
\left\{ {\mathbf{P}_1, \mathbf{P}_2} \left|
{{[\mathbf{P}_1]_{ij} \in \{0, 1\} \atop [\mathbf{P}_2]_{ij} \in \{0, 1\}} \atop
{\sum_{i=1}^m [\mathbf{P}_1]_{ij} = \sum_{j=1}^m [\mathbf{P}_1]_{ij} = 1 \atop
\sum_{i=1}^n [\mathbf{P}_2]_{ij} = \sum_{j=1}^n [\mathbf{P}_2]_{ij} = 1}} \right. \right\}}
f(\mathbf{P}_1, \mathbf{P}_2)
All possible 2-, ..., k-fold permutations of the initial permutation matrices are tried to
identify ones which give a lower value of objective function :math:`f`.
This corresponds to row-swaps for :math:`\mathbf{ P}_1` and column-swaps for :math:`\mathbf{
P}_2`. Starting from these updated permutation matrices, the process is repeated until no
further k-fold reordering of either permutation matrix lower the objective function.
Parameters
----------
fun : callable
The objective function :math:`f` to be minimized.
p1 : ndarray
The 2D-array permutation matrix representing the initial guess for :math:`\mathbf{P}_1`.
p2 : ndarray
The 2D-array permutation matrix representing the initial guess for :math:`\mathbf{P}_2`.
k : int, optional
The order of the permutation. For example, ``k=3`` swaps all possible 3-permutations.
tol : float, optional
When value of the objective function is less than given tolerance, the algorithm stops.
Returns
-------
p1_opt : ndarray
The locally-optimal permutation matrix :math:`\mathbf{P}_1`.
p2_opt : ndarray
The locally-optimal permutation matrix :math:`\mathbf{P}_2`.
f_opt : float
The locally-optimal value of objective function given by :math:`\text{fun(p1_opt, p2_opt)}`.
"""
# pylint: disable=too-many-nested-blocks
# check whether p1 & p2 are square arrays
if p1.ndim != 2 or p1.shape[0] != p1.shape[1]:
raise ValueError(f"Argument p1 should be a square array. Given p1 shape={p1.shape}")
if p2.ndim != 2 or p2.shape[0] != p2.shape[1]:
raise ValueError(f"Argument p2 should be a square array. Given p2 shape={p2.shape}")
# check whether p1 & p2 are valid permutation matrices
if not np.all(np.logical_or(p1 == 0, p1 == 1)):
raise ValueError("Elements of permutation matrix p1 can only be 0 or 1.")
if not np.all(np.logical_or(p2 == 0, p2 == 1)):
raise ValueError("Elements of permutation matrix p2 can only be 0 or 1.")
if np.all(np.sum(p1, axis=0) != 1) or np.all(np.sum(p1, axis=1) != 1):
raise ValueError("Sum over rows or columns of p1 matrix isn't equal 1.")
if np.all(np.sum(p2, axis=0) != 1) or np.all(np.sum(p2, axis=1) != 1):
raise ValueError("Sum over rows or columns of p2 matrix isn't equal 1.")
# check k
if k < 2 or not isinstance(k, (int, np.integer)):
raise ValueError(f"Argument k={k} must be a integer greater than 1. Give type {type(k)}")
if k > max(p1.shape[0], p2.shape[0]):
raise ValueError(f"Argument k={k} is not smaller than {max(p1.shape[0], p2.shape[0])}.")
# compute initial value of the objective function & assign best P1 & P2 matrices
f_opt = fun(p1, p2)
p1_opt, p2_opt = np.copy(p1), np.copy(p2)
# swap rows and columns until the permutation matrix is not improved
search = True
while search:
search = False
# make sure p1 & p2 guesses are the best permutation matrices found thus far
p1, p2 = np.copy(p1_opt), np.copy(p2_opt)
for perm1 in it.permutations(np.arange(p1.shape[0]), r=int(min(k, p1.shape[0]))):
comb1 = tuple(sorted(perm1))
for perm2 in it.permutations(np.arange(p2.shape[0]), r=int(min(k, p2.shape[0]))):
comb2 = tuple(sorted(perm2))
if not (perm1 == comb1 and perm2 == comb2):
# permute rows of matrix P1
perm_p1 = np.copy(p1)
perm_p1[comb1, :] = perm_p1[perm1, :]
# permute rows of matrix P2
perm_p2 = np.copy(p2)
perm_p2[comb2, :] = perm_p2[perm2, :]
# compute objective function for permuted P matrix & compare
perm_f = fun(perm_p1, perm_p2)
if perm_f < f_opt:
p1_opt, p2_opt, f_opt = perm_p1, perm_p2, perm_f
# set search=True to keep permuting the new p1_opt & p2_opt unless this
# is already an exhaustive search
search = bool(k < max(p1.shape[0], p2.shape[0]))
# check whether perfect permutation matrix is found
# TODO: smarter threshold based on norm of matrix
if f_opt <= tol:
return p1_opt, p2_opt, f_opt
return p1_opt, p2_opt, f_opt