Partially for novelty and mostly for completeness, I demonstrate what @Homer512
correctly suggests is possible - a MILP implementation. I expect its accuracy to be excellent and its performance to be somewhere between "poor" and "gruesome".
I demonstrate with a very small problem size so that when you debug and view the constraint matrices they are legible, and so that my RAM doesn't explode.
import time
import numpy as np
import scipy.sparse as sp
from numpy.random import default_rng
from scipy.optimize import milp, Bounds, LinearConstraint
n_A, n_R = 10, 4
rand = default_rng(seed=0)
A = rand.normal(loc=100, scale=50, size=n_A)
lo, hi = A.min(), A.max()
A_order = A.argsort() # for use if you want to restore the original order later
# A = A[A_order] # if you want to modify the algorithm to assume ordered input
'''
decision variables:
nA*nR binary assignments (sparse)
nA discretized values (dense)
nR discretization levels (dense) aka. R
nA errors (dense)
'''
c = np.zeros(n_A*n_R + n_A + n_R + n_A)
c[-n_A:] = 1 # minimize errors
integrality = np.ones(n_A*n_R + n_A + n_R + n_A, dtype=np.uint8)
integrality[n_A*n_R:] = 0 # only assignments are integral
lb = np.full_like(c, -np.inf)
ub = np.full_like(c, +np.inf)
lb[:n_A*n_R] = 0 # assignments are binary
ub[:n_A*n_R] = 1
# Big-M magnitude based on range of input data, without assuming signs
M = 2*max(abs(lo), abs(hi))
# I- Each input value must be assigned exactly one discretized value (Kronecker)
exclusive_assignment = LinearConstraint(
A=sp.hstack((
sp.kron(sp.eye(n_A), np.ones(n_R)),
sp.csc_array((n_A, n_A)),
sp.csc_array((n_A, n_R)),
sp.csc_array((n_A, n_A)),
)),
lb=np.ones(n_A), ub=np.ones(n_A),
)
# II- If assigned, discretized output must be <= level (Kronecker)
# discretized_output <= level + (1-assigned)*M
# assigned*M + discretized_output - level <= M
# III- If assigned, discretized output must be >= level
# discretized_output >= level - (1-assigned)*M
# assigned*-M + discretized_output - level >= -M
discrete_sparse_to_level = LinearConstraint(
A=sp.bmat((
(
sp.eye(n_A*n_R) * M,
sp.kron(sp.eye(n_A), np.ones((n_R, 1))),
sp.csc_array(
np.tile(-np.eye(n_R), (n_A, 1))
),
sp.csc_array((n_A*n_R, n_A)),
),
(
sp.eye(n_A*n_R) * -M,
sp.kron(sp.eye(n_A), np.ones((n_R, 1))),
sp.csc_array(
np.tile(-np.eye(n_R), (n_A, 1))
),
sp.csc_array((n_A*n_R, n_A)),
),
), format='csc'),
lb=np.concatenate((np.full(n_A*n_R, -np.inf), np.full(n_A*n_R, -M))),
ub=np.concatenate((np.full(n_A*n_R, M), np.full(n_A*n_R, np.inf))),
)
# IV- error >= output - input (Kronecker)
# A.assign - output + error >= 0
# V- error >= input - output
# -A.assign + output + error >= 0
abs_error = LinearConstraint(
A=sp.bmat((
(
sp.kron(sp.diags(A), np.ones(n_R)),
-sp.eye(n_A),
sp.csc_array((n_A, n_R)),
np.eye(n_A),
),
(
sp.kron(sp.diags(-A), np.ones(n_R)),
sp.eye(n_A),
sp.csc_array((n_A, n_R)),
np.eye(n_A),
),
), format='csc'),
lb=np.concatenate((np.zeros(n_A), np.zeros(n_A))),
ub=np.concatenate((np.full(n_A, np.inf), np.full(n_A, np.inf))),
)
level_order = LinearConstraint(
A=sp.hstack((
sp.csc_array((n_R-1, n_A*n_R)),
sp.csc_array((n_R-1, n_A)),
sp.eye(n_R-1, n_R, k=1) - sp.eye(n_R-1, n_R),
sp.csc_array((n_R-1, n_A)),
)),
lb=np.zeros(n_R-1),
ub=np.full(n_R-1, np.inf),
)
start = time.perf_counter()
res = milp(
c=c, integrality=integrality, bounds=Bounds(lb=lb, ub=ub),
constraints=(
exclusive_assignment,
abs_error,
discrete_sparse_to_level,
# level_order,
),
)
stop = time.perf_counter()
print(f'Completed in {stop - start:.4f} s')
assert res.success
assign, discretized, levels, errors = np.split(
res.x, (n_A*n_R, n_A*n_R + n_A, -n_A)
)
assign = assign.reshape((n_A, n_R))
print(levels)
print(np.stack((A, discretized, errors), axis=1))
print(assign)