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

Add (iid) truncated normal and also add grad to normal #542

Open
wants to merge 21 commits into
base: main
Choose a base branch
from

Conversation

chaozg
Copy link
Contributor

@chaozg chaozg commented Sep 30, 2024

fixed #321
fixed #548 (grad of Normal was missing)

With this PR, we can draw samples from truncated iid normal distributions, i.e., truncated version of cuqi.distribution.normal or use them as priors.

demo 1: 2D normal distribution N([0; 0], [1, 0; 0, 1]) truncated in square domain [-2,2]*[-2,2]

import cuqi
import matplotlib.pyplot as plt
import numpy as np
from cuqi.utilities import plot_2D_density

tn = cuqi.distribution.TruncatedNormal(np.array([0,0]),np.array([1,1]),np.array([-2,-2]),np.array([2,2]))

plt.figure()
plot_2D_density(tn, -5, 5, -5, 5)

sampler = cuqi.experimental.mcmc.MALA(tn, scale=0.1, initial_point= np.array([0,0]))
sampler.sample(10000)
samples = sampler.get_samples()
plt.figure()
samples.plot_trace()

image
image
image

demo 2: bottom right quarter of 2D normal distribution N([0; 0], [1, 0; 0, 1])

import cuqi
import matplotlib.pyplot as plt
import numpy as np
from cuqi.utilities import plot_2D_density

tn = cuqi.distribution.TruncatedNormal(np.array([0,0]),np.array([1,1]),np.array([0,-np.Inf]),np.array([np.Inf, 0]))

plt.figure()
plot_2D_density(tn, -5, 5, -5, 5)

sampler = cuqi.experimental.mcmc.MALA(tn, scale=0.1, initial_point= np.array([1,-1]))
sampler.sample(10000)
samples = sampler.get_samples()
plt.figure()
samples.plot_trace()

image
image
image

demo 3: simplest BIP

import numpy as np
import matplotlib.pyplot as plt
import cuqi
from cuqi.utilities import plot_2D_density
np.random.seed(0)

# the forward model
A_matrix = np.array([[1.0, 1.0]])
A = cuqi.model.LinearModel(A_matrix)

# the prior
# x = Gaussian(np.zeros(2), 2.5)
# Bottom right quarter of 2D normal distribution
x = cuqi.distribution.TruncatedNormal(np.array([0, 0]), \
    np.array([1, 1]), np.array([0, -np.Inf]), np.array([np.Inf, 0]))
print(x)

# the data distribution
b = cuqi.distribution.Gaussian(A@x, 0.1)

# the observed data
particular_x = np.array([1.5, 1.5])
b_given_particular_x = b(x=particular_x)
b_obs = b_given_particular_x.sample()
print(b_obs)

# the posterior
joint = cuqi.distribution.JointDistribution(x, b)
post = joint(b=b_obs)

# sampling with MALA, ULA and NUTS
sampler = cuqi.experimental.mcmc.MALA(post, initial_point=np.array([2.5, -2.5]), scale=0.03)
# sampler = cuqi.experimental.mcmc.ULA(post, initial_point=np.array([2.5, 2.5]), scale=0.12)
# sampler = cuqi.experimental.mcmc.NUTS(post, initial_point=np.array([2.5, 2.5]))

sampler.warmup(1000)
sampler.sample(1000)
samples = sampler.get_samples().burnthin(1000)
samples.plot_trace()

# plot exact posterior distribution and samples
# the posterior PDF
plt.figure()
plot_2D_density(post, 1, 5, -3, 1)
plt.title("Exact Posterior")

# samples
plt.figure()
samples.plot_pair()
plt.xlim(1, 5)
plt.ylim(-3, 1)
plt.gca().set_aspect('equal')
plt.title("Posterior Samples")

image
image

Copy link
Contributor

@amal-ghamdi amal-ghamdi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @chaozg for adding this very useful distribution! I added some comments, feel free to address them as you see fit.

cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@nabriis nabriis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nice @chaozg. I agree with Amals suggestions and I had a few of my own.


class TruncatedNormal(Distribution):
"""
Truncated Normal probability distribution. Generates instance of cuqi.distribution.TruncatedNormal. It allows the user to specify upper and lower bounds on random variables represented by a Normal distribution. This distribution is suitable for a small dimension setup (e.g. `dim`=3 or 4). Using TruncatedNormal Distribution with a larger dimension can lead to a high rejection rate when used within MCMC samplers.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you break this line into smaller pieces?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done as suggested

cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved
cuqi/distribution/_truncated_normal.py Outdated Show resolved Hide resolved

def _sample(self,N=1, rng=None):
"""
Generates random samples from the distribution.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps for the direct sampling of the distribution we could use a simple "Rejection" sampling? If asked for "N" samples, we sample a Gaussian, say "N" times, remove out of bounds, sample another "N" times, remote out of bounds. If we have now "N" or more sample we return the first N, else we repeat until we get "N" samples. Could also be used to compare with the samplers you showed in the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added_sample with a slightly different strategy: I generate samples one by one, check if it is within the bounds, until we get N samples. Do you think strategy is OK?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a comment here, would it be possible just to pass the rng to sample = self._normal.sample(rng=rng)?

from scipy.special import erf
from cuqi.distribution import Distribution

class TruncatedNormal(Distribution):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You made this really nice showcases in the PR @chaozg. I suggest you add one or two of them as "Examples" in the docstring!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a example in the docstring as suggested

@chaozg chaozg mentioned this pull request Oct 9, 2024
3 tasks
@chaozg
Copy link
Contributor Author

chaozg commented Oct 9, 2024

Thanks @amal-ghamdi and @nabriis for your review. I think I have addressed your comments so I'm requesting your further review here.

Note that the existing Normal lacks grad() #548 , so I also add it in this PR.

Note that sample() implemented lacks a way to carry rng, and I'm not sure what's the best way to solve this.

  • For now, I put TruncatedNormal in the skip_sample list so the sampling is not test in test_multivariate_scalar_vars_samplewith a given rng;
  • Meanwhile, I created a test test_TruncatedNormal_sampling which checks if all samples falls in the bounds, but it's not tested with a fixed rng.

@chaozg chaozg changed the title Add (iid) truncated normal Add (iid) truncated normal and also add grad to normal Oct 10, 2024
Copy link
Contributor

@amal-ghamdi amal-ghamdi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks @chaozg for the updates! it is nice that the distribution is based on Normal now and that you added the gradient for the Normal distribution. I have only few comments to consider as you see fit.

cuqi/distribution/_truncated_normal.py Show resolved Hide resolved
Comment on lines +39 to +41
def gradient(self, x):
return -(x-self.mean)/(self.std**2)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice bonus that we have a gradient for normal now :). Just one suggestion to add two checks:
1- the geometry is not a geometry that needs a chain rule.
2- The distribution is used as prior, not likelihood because we do not account for the chain rule here.

For example, the gradient implementation of cmrf does these two checks

    def _gradient(self, val, **kwargs):
        #Avoid complicated geometries that change the gradient.
        if not type(self.geometry) in _get_identity_geometries():
            raise NotImplementedError("Gradient not implemented for distribution {} with geometry {}".format(self,self.geometry))

        if not callable(self.location): # for prior
            diff = self._diff_op._matrix @ val
            return (-2*diff/(diff**2+self.scale**2)) @ self._diff_op._matrix
        else:
            warnings.warn('Gradient not implemented for {}'.format(type(self.location)))


def _sample(self,N=1, rng=None):
"""
Generates random samples from the distribution.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a comment here, would it be possible just to pass the rng to sample = self._normal.sample(rng=rng)?

if np.all(point >= low) and np.all(point <= high):
assert x_trun.logpdf(point) == approx(x.logpdf(point))
else:
assert np.isinf(x_trun.logpdf(point))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a suggestion to use np.isneginf instead of np.isinf here, to check for negative infinity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add gradient to normal Add truncated normal distribution
3 participants