Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added optimal transport example #43

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions examples/torch/optimal_transport.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 186,
"metadata": {},
"outputs": [],
"source": [
"from cvxpylayers.torch import CvxpyLayer\n",
"import torch\n",
"import cvxpy as cp\n",
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"\n",
"torch.set_default_dtype(torch.double)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Differentiable optimal transport\n",
"\n",
"Suppose we have two one-dimensional probability distributions, $\\nu$ and $\\eta$, defined\n",
"by their supports $x\\in\\mathbf{R}^n$, $y\\in\\mathbf{R}^m$, and probability weight vectors $a\\in\\mathbf{R}_+^n$, $b\\in\\mathbf{R}_+^m$.\n",
"That is, $\\nu=x_i$ with probability $a_i$, and\n",
"$\\eta=y_j$ with probability $b_j$.\n",
"\n",
"In optimal transport, the goal is to find a matrix $P\\in\\mathbf{R}_+^{n\\times m}$ (the set of nonnegative $n$ by $m$ matrices) that *transports*\n",
"$\\nu$ into $\\eta$. The entry $P_{ij}$ tells us how much of the probability mass at $x_i$\n",
"that we transfer to $y_j$. We have two constraints:\n",
"* $\\sum_{j} P_{ij} = a_i$, $i=1,\\ldots,n$, which requires that the mass leaving $x_i$ is equal to the mass available, $a_i$.\n",
"* $\\sum_{i} P_{ij} = b_j$, $j=1,\\ldots,m$, which requires that the mass entering $y_i$ is equal to the mass there, $b_j$.\n",
"\n",
"For each mass transfer $P_{ij}$, we associate a cost $C_{ij} = h(x_i - y_j)$, where $h(d)$ measures\n",
"the cost of transferring a unit a distance $d$. An example is $h(d)=d^2$; in this case $C_{ij}$ measures\n",
"the squared difference between $x_i$ and $y_j$.\n",
"\n",
"We can find the (regularized) optimal transport between $\\nu$ and $\\eta$ by solving the optimization problem\n",
"\\begin{equation}\n",
"\\begin{array}{ll}\n",
"\\mbox{minimize} & \\mathbf{tr}(C^TP) - \\varepsilon H(P), \\\\\n",
"\\mbox{subject to} & P \\in \\mathbf{R}_+^{n \\times m}, \\\\\n",
"& P\\mathbf{1}=a, \\quad P^T\\mathbf{1}=b.\n",
"\\end{array}\n",
"\\end{equation}\n",
"where $H(P) = -\\sum_{ij} P_{ij} (\\log(P_{ij}) - 1)$ is an entropy regularization function and $\\varepsilon > 0$ is a hyper-parameter.\n",
"This is a convex optimization problem.\n",
"\n",
"See:\n",
"Leonid Kantorovich. On the transfer of masses (in russian). Doklady Akademii Nauk, 37(2): 227–229, 1942"
]
},
{
"cell_type": "code",
"execution_count": 187,
"metadata": {},
"outputs": [],
"source": [
"def get_regularized_ot(n, m):\n",
" C = cp.Parameter((n, m))\n",
" a = cp.Parameter(n)\n",
" b = cp.Parameter(m)\n",
" P = cp.Variable((n, m))\n",
" eps = cp.Parameter(1, nonneg=True)\n",
"\n",
" H = lambda P: cp.sum(cp.entr(P)) + cp.sum(P)\n",
" objective = cp.trace(P.T @ C) - eps * H(P)\n",
"\n",
" prob = cp.Problem(cp.Minimize(objective), [[email protected](m) == a, [email protected](n) == b, P >= 0])\n",
"\n",
" layer = CvxpyLayer(prob, [C, a, b, eps], [P])\n",
" def f(x, y, a, b, eps, h=lambda d: d.pow(2)):\n",
" \"\"\"Regularized optimal transport.\n",
"\n",
" x: n-vector\n",
" y: m-vector\n",
" a: n-vector of probabilities\n",
" b: m-vector of probabilities\n",
" eps: scalar, amount of regularization\n",
" \"\"\"\n",
" C = h(x[:,None] - y[None,:])\n",
" return layer(C, a, b, eps)[0]\n",
" return f"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Example"
]
},
{
"cell_type": "code",
"execution_count": 188,
"metadata": {},
"outputs": [],
"source": [
"n, m = 3, 3\n",
"regularized_ot = get_regularized_ot(n, m)"
]
},
{
"cell_type": "code",
"execution_count": 189,
"metadata": {},
"outputs": [],
"source": [
"torch.manual_seed(6)\n",
"x = torch.randn(n, requires_grad=True)\n",
"y = torch.randn(m, requires_grad=True)\n",
"a = torch.full((n,), 1./n, requires_grad=True)\n",
"b = torch.full((m,), 1./m, requires_grad=True)\n",
"eps = torch.tensor([1.], requires_grad=True)"
]
},
{
"cell_type": "code",
"execution_count": 190,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"<BarContainer object of 3 artists>"
]
},
"execution_count": 190,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"image/png": "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\n",
"text/plain": [
"<Figure size 432x288 with 1 Axes>"
]
},
"metadata": {
"needs_background": "light"
},
"output_type": "display_data"
}
],
"source": [
"plt.xlim(-2,2)\n",
"plt.bar(x.detach().numpy(), a.detach().numpy(), width=.05)\n",
"plt.bar(y.detach().numpy(), b.detach().numpy(), width=.05)"
]
},
{
"cell_type": "code",
"execution_count": 191,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"WARN: aa_init returned NULL, no acceleration applied.\n"
]
}
],
"source": [
"P = regularized_ot(x, y, a, b, eps)"
]
},
{
"cell_type": "code",
"execution_count": 192,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"tensor([-1.8744, -0.9937, 0.7185], requires_grad=True)"
]
},
"execution_count": 192,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"x"
]
},
{
"cell_type": "code",
"execution_count": 193,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"tensor([-0.6985, -1.4716, 0.1777], requires_grad=True)"
]
},
"execution_count": 193,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"y"
]
},
{
"cell_type": "code",
"execution_count": 194,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tensor([[0.1007, 0.2269, 0.0058],\n",
" [0.1807, 0.1043, 0.0483],\n",
" [0.0520, 0.0021, 0.2792]], grad_fn=<_CvxpyLayerFnFnBackward>)\n"
]
}
],
"source": [
"print(P)"
]
},
{
"cell_type": "code",
"execution_count": 195,
"metadata": {},
"outputs": [],
"source": [
"# What would we have to do to increase the mass that we transfer from x = .7185 to y=.1777\n",
"P[2,2].backward()"
]
},
{
"cell_type": "code",
"execution_count": 196,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"tensor([-0.0010, -0.0401, 0.0411])"
]
},
"execution_count": 196,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# increase x[2] and decrease x[0:2]\n",
"x.grad"
]
},
{
"cell_type": "code",
"execution_count": 197,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"tensor([-0.0826, 0.0007, 0.0819])"
]
},
"execution_count": 197,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# increase y[2] and decrease y[0], but increase y[1]?\n",
"y.grad"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.3"
}
},
"nbformat": 4,
"nbformat_minor": 2
}