From 970163a34770bca0daac7cb727627e45fa03c169 Mon Sep 17 00:00:00 2001 From: Shane Barratt Date: Sat, 25 Jan 2020 14:43:57 -0800 Subject: [PATCH] added optimal transport example --- examples/torch/optimal_transport.ipynb | 302 +++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 examples/torch/optimal_transport.ipynb diff --git a/examples/torch/optimal_transport.ipynb b/examples/torch/optimal_transport.ipynb new file mode 100644 index 0000000..333f79b --- /dev/null +++ b/examples/torch/optimal_transport.ipynb @@ -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), [P@np.ones(m) == a, P.T@np.ones(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": [ + "" + ] + }, + "execution_count": 190, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "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 +}