The binary source model

Contents

This is a brief notebook, but it is heavily reliant on the concepts covered in the Single Lens notebook. If you have not completed that notebook, perhaps it would be best if you did that and then came back.

If you haven't already done so, install MulensModel before continuing with this notebook:

pip install MulensModel
# package imports (SHIFT + ENTER to run)
import numpy as np
import emcee
import MulensModel as mm
import matplotlib.pyplot as plt
import multiprocessing as mp
from IPython.display import display, clear_output
from typing import List, Tuple, Optional, Union

# dev note: replace emcee with dynesty?

The binary source model#

Events matching a one-lens, two-sources model typically have lightcurves with smooth non-Paczynski shapes, such as the one in Jung et al. (2017), Figure 5, which is shown below (event OGLE-2016-BLG-0733). They are, in effect, two distinct Paczynski curves on top of each other; they do not create "sharp" perturbations from the primary Paczynski curve, like the binary-lens models typically do.

Jung 2017 Figure 5

Jung 2017 Figure 3

[Reproduction of Jung et al. (2017), Figure 5] Geometry and lightcurve of the binary-source model. Top: The upper panel shows the geometry of the binary-source model. Two straight lines with arrows are the source trajectories of individual source stars. The lens is located at the origin (marked by M), and the dotted circle is the angular Einstein ring. The red and blue filled circles represent the individual source positions at $\rm{HJD}'=7497$. Lengths are normalised by the Einstein radius. Bottom: The lower panel shows the enlarged view of the anomaly region. The two curves with different colours are best-fit binary-source models for $RI$ and $I$ passbands. The inset shows a zoom of the anomaly near $\rm{HJD}'\sim7501.4$. We note that, although we only use the $V$-band data for determining the source type, we also present the $V$-band model lightcurve to compare the colour change between passbands.

[Reproduction of Jung et al. (2017), Figure 3] Geometry and lightcurve of the binary lens model. Top: The upper panel shows the geometry of the binary lens model. The straight line with an arrow is the source trajectory, red closed concave curves represent the caustics, and blue filled circles (marked by $M_1$ and $M_2$) are the binary lens components. All length scales are normalised by the Einstein radius. The inset shows the general view and the major panel shows the enlarged view corresponding to the lightcurve of the lower panel. The open circle on the source trajectory is the source position at the time of observation whose size represents the source size. Bottom: The lower panel shows the enlarged view of the anomaly region. The inset shows a zoom of the lightcurve near $\rm{HJD}'\sim7501.4$. The curve superposed on the data is the best-fit binary lens model.

The magnification model has the same parameterisation as the single lens model, except the flux model is a sum of two single-source magnifications multiplied by the flux of the source they are lensing: $$ \mathbf{F}=\mathbf{A_1}F_{\rm S1}+\mathbf{A_2}F_{\rm S2}+F_{\rm B}, $$ where $\mathbf{A_1}$ and $F_{\rm S1}$ are the primary-source magnification model and flux, $\mathbf{A_2}$ and $F_{\rm S2}$ are the secondary-source magnification model and flux, $F_{\rm B}$ is the blend flux, and the models $\mathbf{A_1}$ and $\mathbf{A_2}$ share the same physical lens parameters $(D_{\rm L},,M_{\rm L})$.

We can see the model in practice in the code cell below:

# A simulated binary source event

t01, u01 = 6100., 0.2  # primary source model parameters
t02, u02 = 6140., 0.2  # secondary source model parameters
t_E = 25.   # tE is not necessarily the same, due to orbital motion of the lens system, but we would expect 
            # tE to be mostly due to the relative propermotion of the lens and source systems.
flux1, flux_ratio, blend_flux = 100., 0.1, 50.
n = 500  # number of data points
time = np.linspace(6000., 6300., n)  # "data" epochs
N = 1000  # model plotting precision
T = np.linspace(6000., 6300., N)  # model time array

# building the individual magnification models for each source star
model1 = mm.Model({'t_0': t01, 'u_0': u01, 't_E': t_E})
A1 = model1.get_magnification(time)
model2 = mm.Model({'t_0': t02, 'u_0': u02, 't_E': t_E})
A2 = model2.get_magnification(time)

flux = A1 * flux1 + A2 * flux1*flux_ratio + blend_flux
flux_err = 3.0 *(np.abs(5.0 + np.random.normal(size=n)))  # flux uncertainties with mu=15 and std=3
error = flux_err * np.random.normal(size=n)     # offset the "meaasurments" by an amount that is dependent    
flux += error                                   # on the uncertainty
data = [time, flux, flux_err]

my_dataset = mm.MulensData(data, phot_fmt='flux')

plt.close(1)
plt.figure(1)

# Plot the data
plt.errorbar(time, flux, yerr=flux_err, 
             fmt='x', 
             ecolor='k', 
             capsize=1, 
             color='k', 
             alpha=0.6, 
             zorder=0,
             label='simulated data'
             )
# Plot the model
F = model1.get_magnification(T) * flux1 + model2.get_magnification(T) * flux1*flux_ratio + blend_flux
plt.plot(T, F, 
         color='r', 
         linestyle='-', 
         lw=1, 
         zorder=1, 
         label='1L2S model'
         )

plt.legend()
plt.xlabel(r'HJD (days)')
plt.ylabel(r'flux')
plt.show()
../_images/2aa8ca447ab2e1b94cc841525b708317b46259c8838e7e794ac1966f768298ea.png

The measure of how far the data are from the model solution, scaled by the data uncertainties ($\chi^2$), is calculated using $$ \chi^2 = \sum\limits_i \frac{\left(F_i - x_i\right)^2} {\sigma_i^2}. $$ $\chi^2$ quantifies how likely a parameter set is to have generated the observed data, for a given model.

Similar to the procedure in the Single Lens notebook, the maximum likelihood solution $(F_{\rm S1}, F_{\rm S2}, F_{\rm B})$ can therefore be found by solving for the minima of this function. As in the previous section, the minima occurs where the partial derivatives of the $\chi^2$ function are 0; $$ \frac{\partial\chi^2}{\partial F_{\rm S1}} = 0, \frac{\partial\chi^2}{\partial F_{\rm S2}} = 0, \frac{\partial\chi^2}{\partial F_{\rm B}} = 0. $$ This linear regression problem can be written in the form $$ \begin{matrix} \begin{bmatrix} \sum\limits_{i}\frac{A_{1,i}^{2}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{A_{1,i}A_{2,i}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{A_{1,i}}{\sigma_{i}^{2}} \ \sum\limits_{i}\frac{A_{1,i}A_{2,i}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{A_{2,i}^{2}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{A_{2,i}}{\sigma_{i}^{2}} \ \sum\limits_{i}\frac{A_{1,i}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{A_{2,i}}{\sigma_{i}^{2}} & \sum\limits_{i}\frac{1}{\sigma_{i}^{2}} \end{bmatrix} & \times & \begin{bmatrix} F_{\rm S1} \ F_{\rm S2} \ F_{\rm B} \end{bmatrix} & = & \begin{bmatrix} \sum\limits_{i}\frac{A_{1,i}x_{i}}{\sigma_{i}^{2}} \ \sum\limits_{i}\frac{A_{2,i}x_{i}}{\sigma_{i}^{2}} \ \sum\limits_{i}\frac{x_{i}}{\sigma_{i}^{2}} \end{bmatrix} \ \mathbf{B} &\times& \mathbf{\Theta} &=& \mathbf{C}, \end{matrix} $$

which is solved following as follows: $$ \mathbf{\Theta} = \mathbf{B}^{-1} \mathbf{C} = \frac{adj,\mathbf{B}}{\det\mathbf{B}} \cdot \mathbf{C}. $$

Exercise 1

Use linear regression to determine the flux of the primary source, secondary source, and blend stars. Did you get out something similar to what you put in?


You may want to consider using functions from the numpy.linalg module.


# objective function
def binary_source_chi2(theta: np.ndarray, 
                       model1: mm.Model, 
                       model2: mm.Model, 
                       data: List, 
                       verbose: Optional[bool] = False,
                       return_fluxes: Optional[bool] = False
                       ) -> float:
    """
    chi2 function for a binary-source, single-lens, microlensing model.
    
    Parameters
    ----------
    theta : np.ndarray
        Array of model parameters being fit.
    model1 : mm.Model
        Primary source model.
    model2 : mm.Model
        Secondary source model.
    data : list
        List of data arrays.
    verbose : bool, optional
        Default is False.
        Print the primary source flux, secondary source flux, and blend flux.

    Returns
    -------
    float
        The chi2 value.

    Notes
    -----
    The model parameters are unpacked from theta and set to the model1 and model2 parameters.
    model1 and model2 are MulensModel.Model objects; see MulensModel documentation 
    (https://rpoleski.github.io/MulensModel/) for more information.
    """
    #unpack the data
    t, flux, flux_err = data

    # model parameters being fit
    labels = ['t_0', 'u_0', 't_E']

    # change the values of model.parameters to those in theta.
    theta1 = theta[:3]
    theta2 = theta[3:]
    for (label, value1, value2) in zip(labels, theta1, theta2):
        setattr(model1.parameters, label, value1)
        setattr(model2.parameters, label, value2)
    
    # calculate the model magnification for each source
    A1 = model1.get_magnification(t)
    A2 = model2.get_magnification(t)

    ######################
    # EXERCISE: BinarySourceE1.txt
    #---------------------
    # build the matricies for the linear algebra
    C = np.array([np.sum(A1 * flux * flux_err**-2), np.sum(A2 * flux * flux_err**-2), np.sum(flux * flux_err**-2)])
    B = np.array([[np.sum(A1**2 * flux_err**-2), np.sum(A1 * A2 * flux_err**-2), np.sum(A1 * flux_err**-2)],
                  [np.sum(A1 * A2 * flux_err**-2), np.sum(A2**2 * flux_err**-2), np.sum(A2 * flux_err**-2)],
                  [np.sum(A1 * flux_err**-2), np.sum(A2 * flux_err**-2), np.sum(flux_err**-2)]])
    
    # calculate the flux components
    Theta = np.linalg.solve(B, C)  # ax=b <- B x Theta = C
    FS1, FS2, FB = Theta # primary source flux, secondary source flux, and blend flux
    ######################

    # print the flux parameters
    if verbose:
        print(f"Primary source flux: {FS1}")
        print(f"Secondary source flux: {FS2}")
        print(f"Blend flux: {FB}")

    # calculate the model flux
    model_flux = A1 * FS1 + A2 * FS2 + FB
    chi2 = np.sum(((flux - model_flux) / flux_err)**2)

    # In case something goes wrong with the linear algebra
    if np.isnan(chi2) or np.isinf(chi2):
        print(f"NaN or inf encountered in chi2 calculation: theta={theta}, chi2={chi2}")
        return 1e16

    if return_fluxes:
        return chi2, FS1, FS2, FB
    else:
        return chi2


# generative model parameters
t01, u01 = 6100., 0.2
t02, u02 = 6140., 0.2
t_E = 25.
theta = np.array([t01, u01, t_E, t02, u02, t_E])

# initial chi2 value (will print the fluxes)
chi2 = binary_source_chi2(theta, model1, model2, data, verbose=True)

# generative fluxes
# source 2 flux used to generate the data
flux2 = flux1 * flux_ratio
print('\nprimary source flux used to generate the data:', flux1)
print('secondary source flux used to generate the data:', flux2)
print('blend flux used to generate the data:', blend_flux)
Primary source flux: 100.3695654609157
Secondary source flux: 11.241566685226797
Blend flux: 47.828158251258074

primary source flux used to generate the data: 100.0
secondary source flux used to generate the data: 10.0
blend flux used to generate the data: 50.0

The noise we generated in the light curve should mean that, even though we used the "true" magnification-model parameters, the flux values we retrieve are not exactly the same as those we used to generate the lightcurve. But they should be close (i.e. within a few flux units). If you got values that are wildly different something has gone wrong. It could be a fluke, so start be regenerating your noisy data. If that doesn't fix the problem you will need to take another look at your $\chi^2$ function (Exercise 1).

Exercise 2

Make an initial guess at the parameters for this model.


I know, we know them. Pretend we don't, okay.


######################
# EXERCISE: BinarySourceE2.txt
#---------------------
# initial guess
t01 = 6100
t02 = 6140
u01 = 0.2
u02 = 0.3
tE1 = 25
tE2 = 25

# fitting the model
theta0 = [t01, u01, tE1, t02, u02, tE2]
######################

Next we are going to try fitting the data. Even though the likelihood space for a binary-source model should be reasonably well behaved, we will fit this simulated event using MCMC (emcee), because we want to take a look at the posteriors from the fit, so that we can understand if there are any degeneracies between magnification-model parameters. We can use emcee to both fit and collect our posterior samples.

If that all sounded like a forgein language to you, you might need to check-out, or revist, the Modelling notebook.

Exercise 3

Apply some reasonable bounds on the magnification-model parameters (theta).


Replace "True" with your conditions for what constitutes unresonable model-parameter values.


def ln_prob(theta: np.ndarray, model1, model2, data) -> float:
    """log probability"""

    lp = 0.0

    ######################
    # EXERCISE: BinarySourceE3.txt
    #---------------------
    # priors                         # replace True with conditions

    # add a constraint on tEs with a 1 sig deltatE of 5 days
    lp += -(theta[2]-theta[5])**2/5**2   # this discourages the tEs from being too different

    if np.any(np.array(theta) < 0) or theta[0] > theta[3] or theta[0] < 6000 or theta[3] < 6000 or theta[0] > 6300 or theta[3] > 6300:
        return -1e16
    
    elif (theta[2]-theta[5])**2 > 20**2:  # max 20 days difference
        return -1e16
    ######################

    else: # the proposed parameters values are reasonable

        ######################
        # EXERCISE: BinarySourceE7.txt
        #---------------------
        #chi2 = binary_source_chi2(theta, model1, model2, data, return_fluxes=False)
        chi2, FS1, FS2, FB = binary_source_chi2(theta, model1, model2, data, return_fluxes=True)
        
        # implement a prior demanding positive fluxes
        if FS1 < 0 or FS2 < 0 or FB < 0:
            lp = -1e16
        ######################

        # return the log probability
        return -0.5 * chi2 + lp

# starting lnL
lnP = ln_prob(theta0, model1, model2, data)
print('initial log probability:', lnP)
initial log probability: -242.42329940055856

This model is pretty simple. It shouldn't take too long to converge; 1000 steps should be more than enough.

Exercise 4

Set up the emcee sample by choosing the fitting parameters you would like to use.


Note. there is only one correct value for ndim, but the other parameters are more nebulous.


# emcee parameters
nsteps = 1000
######################
# EXERCISE: BinarySourceE4.txt
#---------------------
nwalkers = 100
ndim = len(theta0)
steps_between_plot_updates = 100
######################

# Set up the sampler
sampler = emcee.EnsembleSampler(nwalkers, ndim, ln_prob, 
                                args=[model1, model2, data])

Exercise 5

Create an initial state for you walkers.


Note. These should be "random" values, close to your initial guess; the walkers should start in a hyper-dimensional ball in parameter space, about the inital guess. You might want to consider the np.random.randn function.


######################
# EXERCISE: BinarySourceE5.txt
#---------------------
# Initialize the walkers
initial_pos = theta0 + 1e-6 * np.random.randn(nwalkers, ndim)
######################

Exercise 6

Run the sampler. Do you notice anything strange about any of the chains?


# Function to update the plot
def update_plot(sampler, nwalkers, fig, axes):
    ylabels = [r'$t_{0,1}$', r'$u_{0,1}$', r'$t_{\rm{E},1}$', 
               r'$t_{0,2}$', r'$u_{0,2}$', r'$t_{\rm{E},2}$',
               r'$\ln{P}$']
    for j in range(ndim):
        axes[j].clear()
        for i in range(nwalkers):
            axes[j].plot(sampler.chain[i, :, j], 'k', alpha=0.1)
        axes[j].set_ylabel(ylabels[j])
    
    axes[ndim].clear()
    for i in range(nwalkers):
        axes[ndim].plot(sampler.lnprobability[i], 'r', alpha=0.1)
    axes[ndim].set_ylabel(ylabels[ndim])
    axes[ndim].set_xlabel('step')

    clear_output(wait=True)
    display(fig)
    fig.canvas.draw()
    fig.canvas.flush_events()

def run_emcee(sampler: emcee.EnsembleSampler,
              initial_pos: np.ndarray,
              nsteps: int,
              steps_between_plot_updates: int,
              nwalkers: int,
              ndim: int,
              verbose: Optional[bool] = True
              ) -> Tuple[emcee.EnsembleSampler, np.ndarray]:
    """
    Run the emcee sampler and update the plot.

    Parameters
    ----------
    sampler : emcee.EnsembleSampler
        The sampler object.
    initial_pos : np.ndarray
        The initial position of the walkers.
    nsteps : int
        The number of steps to run the sampler.
    steps_between_plot_updates : int
        The number of steps between plot updates.
    nwalkers : int
        The number of walkers.
    ndim : int
        The number of dimensions.
    verbose : bool, optional
        Default is False.
        Print the progress of the sampler.
    """
    # Create the figure and axes
    fig, axes = plt.subplots(ndim + 1, 1, figsize=(10, 15));

    # Run the sampler in steps and update the plot
    i = 0
    while i < nsteps:
        if i == 0:
            state = sampler.run_mcmc(initial_pos, steps_between_plot_updates, progress=verbose)
        else:
            state = sampler.run_mcmc(state, steps_between_plot_updates, progress=verbose)
        update_plot(sampler, nwalkers, fig, axes)
        i += steps_between_plot_updates
    clear_output(wait=True)  # extra stuff to stop the notebook displaying the final plot twice

    # Access the final state
    final_state = sampler.get_last_sample().coords

    return sampler, final_state

# Run the sampler
sampler.reset() # reset the sampler (you will need this if you want to run_emee fresh, from initial_pos)
sampler, final_state = run_emcee(sampler, initial_pos, nsteps, 
                                 steps_between_plot_updates, nwalkers, ndim)

# continue running  (use this if you want more steps after the first run)
#sampler, final_state = run_emcee(sampler, final_state, nsteps, 
#                                 steps_between_plot_updates, nwalkers, ndim)
../_images/860a57be7999af1725a07a47f035f40a8186112b93405524e55cc974d8f0e0b1.png

Depnding on the priors you chose and the random errors you generated, you may find that u02, tE2, or both are very poorly contrained; this would look like a large spread in the chains for those parameters. You could consider a prior that links the tE values to something similar to each other. But we may also decide to apply a prior constraint on the blend flux, which we reasonable know should not be negative for simulated data.

Why don't we take a look at the flux parameters for the final state and see if we can figure out what is going on?

# corner-like scatter plot of final state: u02, t02, FS2, FB
def plot_corner(final_state: np.ndarray, 
                model1: mm.Model, 
                model2: mm.Model, 
                data: list, 
                figure_number: Optional[int] = 3
                ) -> None:
    """
    Plot the corner-like scatter plot of the final state.
    
    Parameters
    ----------
    final_state : np.ndarray
        Array of final state parameters.
    model1 : mm.Model
        Primary source model.
    model2 : mm.Model
        Secondary source model.
    data : list
        List of data arrays.
    figure_number : int, optional
        Default is 3.
        The figure number.
    """
    # labels
    labels = [r'$t_{0,1}$', r'$u_{0,1}$', r'$t_{E,1}$', r'$t_{0,2}$', r'$u_{0,2}$', r'$t_{E,2}$', r'$F_{S,1}$', r'$F_{S,2}$', r'$F_B$']

    # collect the data dor plotting
    corner_array = np.zeros((len(final_state), ndim + 4))
    corner_array[:, 0:ndim] = final_state

    for i in range(len(final_state)):
        chi2, FS1, FS2, FB = binary_source_chi2(final_state[i], model1, model2, data, return_fluxes=True)
        corner_array[i,ndim] = FS1
        corner_array[i,ndim + 1] = FS2
        corner_array[i,ndim + 2] = FB
        corner_array[i,ndim + 3] = chi2

    # plot
    plt.close(figure_number)
    plt.figure(figure_number)
    fig, axes = plt.subplots(ndim + 3, ndim + 3, figsize=(15, 15))

    for i in range(ndim+3):
        for j in range(ndim+3):
            if i > j:  # plot the scatter plots
                scatter = axes[i, j].scatter(corner_array[:, j], 
                                            corner_array[:, i], 
                                            c=corner_array[:, -1], 
                                            cmap='viridis_r', 
                                            s=0.5
                                            )

                if i == ndim + 2:
                    axes[i, j].set_xlabel(labels[j])
                else: #turn of ticks labels
                    axes[i, j].set_xticks([])

                if j == 0:
                    axes[i, j].set_ylabel(labels[i])
                else: #turn of ticks labels
                    axes[i, j].set_yticks([])

            elif i == j:  # plot the histograms

                axes[i, j].hist(corner_array[:, j], bins=20, color='k')

                if i == ndim + 2:
                    axes[i, j].set_xlabel(labels[j])
                else:
                    axes[i, j].set_xticks([])
                
                axes[i, j].set_yticks([])

            else:  # turn off the upper triangle of axis
                axes[i,j].axis('off')


    # Add the colorbar using the scatter plot object
    cbar = fig.colorbar(scatter, orientation='horizontal', ax=axes, aspect=100)
    cbar.set_label('chi2')

    plt.show()

plot_corner(final_state, model1, model2, data)
<Figure size 640x480 with 0 Axes>
../_images/5144b9bbd27061dc8b44f48f17eaab751f3ce23e4360650815517d44566b73b4.png

Do you see the problem? Negative blend-flux values are allowing for overly high $F_{\rm{S},2}$ values, in combination with correspondong low $t_{\rm{E},2}$ and high $u_{0,2}$. We can avoid this problem by not allowing negative blending, which, considering our data was generated without using photometry, makes absolutely no sense. For events found in real data a small negative flux can be plausible as it is an indication that the "zero flux" background-level in the photometry was actually measuring some flux from stars. In these cases, the blend flux should actually have been measured as very small or 0.

Exercise 7

Add a prior on blend flux in ln_prob() function (Exercies 3) and any other additional priors you think seem reasonable.


Exercise 8

Rerun the sampler and plot the results.


######################
# EXERCISE: BinarySourceE8.txt PART: 1
#---------------------
# re make the sampler  (Make sure to run the ln_prob cell after you edit it)
sampler = emcee.EnsembleSampler(nwalkers, ndim, ln_prob, 
                                args=[model1, model2, data])

# rerun the sampler
run_emcee(sampler, initial_pos, nsteps, steps_between_plot_updates, nwalkers, ndim)
######################
(<emcee.ensemble.EnsembleSampler at 0x7f9072eb7e10>,
 array([[6.10001080e+03, 2.10999062e-01, 2.38183317e+01, 6.13988889e+03,
         4.53665491e-01, 2.20760790e+01],
        [6.10007632e+03, 1.98651275e-01, 2.32952830e+01, 6.13925238e+03,
         5.53186170e-01, 2.15254281e+01],
        [6.09994340e+03, 2.30618400e-01, 2.19053631e+01, 6.14015998e+03,
         4.20068419e-01, 2.19220191e+01],
        [6.10008395e+03, 1.95971288e-01, 2.44752628e+01, 6.13974256e+03,
         2.68335690e-01, 2.44125073e+01],
        [6.10006464e+03, 2.37589492e-01, 2.16657107e+01, 6.13936397e+03,
         2.83052576e-01, 2.37719653e+01],
        [6.10000444e+03, 2.20344268e-01, 2.22454747e+01, 6.14012805e+03,
         3.68533678e-01, 1.98543482e+01],
        [6.10015015e+03, 2.28797630e-01, 2.12954160e+01, 6.13868422e+03,
         3.80693510e-01, 2.30166846e+01],
        [6.10021070e+03, 1.78816451e-01, 2.62934102e+01, 6.13893702e+03,
         3.17628645e-01, 2.42120465e+01],
        [6.10009711e+03, 2.20573612e-01, 2.25706841e+01, 6.13988577e+03,
         3.72933798e-01, 2.14487053e+01],
        [6.10001081e+03, 2.01875421e-01, 2.37533335e+01, 6.13887107e+03,
         3.28892549e-01, 2.35645858e+01],
        [6.09994511e+03, 2.17447885e-01, 2.23776515e+01, 6.13795618e+03,
         4.16397288e-01, 2.16083748e+01],
        [6.09998809e+03, 1.95084881e-01, 2.37078806e+01, 6.13766561e+03,
         3.08087109e-01, 2.63029396e+01],
        [6.10003062e+03, 2.02585271e-01, 2.41681890e+01, 6.13866705e+03,
         3.03668324e-01, 2.21376356e+01],
        [6.10003642e+03, 1.81321842e-01, 2.54205110e+01, 6.13952280e+03,
         3.67787632e-01, 2.26895385e+01],
        [6.10011523e+03, 2.27640061e-01, 2.23174044e+01, 6.13790122e+03,
         3.90451313e-01, 2.45301188e+01],
        [6.10002085e+03, 2.25555398e-01, 2.30098398e+01, 6.13774825e+03,
         4.70610980e-01, 2.18894363e+01],
        [6.10010902e+03, 2.02122930e-01, 2.44581307e+01, 6.13988922e+03,
         2.09246012e-01, 2.64153131e+01],
        [6.09996691e+03, 2.23559621e-01, 2.24849684e+01, 6.13675855e+03,
         3.34007952e-01, 2.64813242e+01],
        [6.10005473e+03, 2.21072481e-01, 2.28748144e+01, 6.13973380e+03,
         3.17926866e-01, 2.43212586e+01],
        [6.09993150e+03, 2.47287415e-01, 2.10999729e+01, 6.13852728e+03,
         3.56830647e-01, 2.24631604e+01],
        [6.10000696e+03, 2.14263628e-01, 2.34934306e+01, 6.13836122e+03,
         3.09003392e-01, 2.50490206e+01],
        [6.09993081e+03, 1.96551316e-01, 2.37557790e+01, 6.13927587e+03,
         3.01116087e-01, 2.83011900e+01],
        [6.10005903e+03, 2.09649725e-01, 2.30960920e+01, 6.13935490e+03,
         4.27471243e-01, 2.18628034e+01],
        [6.09999187e+03, 1.93785585e-01, 2.41260711e+01, 6.13926821e+03,
         3.22643114e-01, 2.42544738e+01],
        [6.09998857e+03, 2.05813245e-01, 2.32916969e+01, 6.13954075e+03,
         3.42113636e-01, 3.02405587e+01],
        [6.10006669e+03, 2.23189384e-01, 2.16752242e+01, 6.13793376e+03,
         2.76749334e-01, 2.59289887e+01],
        [6.09995803e+03, 2.23275745e-01, 2.23003697e+01, 6.13866078e+03,
         4.68803470e-01, 1.67714880e+01],
        [6.10013188e+03, 2.07500380e-01, 2.33067953e+01, 6.13873398e+03,
         1.83959196e-01, 3.24645555e+01],
        [6.09994328e+03, 2.28788452e-01, 2.18466823e+01, 6.13722093e+03,
         5.57292706e-01, 2.02718755e+01],
        [6.10001909e+03, 1.93669456e-01, 2.51576888e+01, 6.13963755e+03,
         3.08498925e-01, 2.44428323e+01],
        [6.10002732e+03, 2.54676383e-01, 2.07280996e+01, 6.13956325e+03,
         3.42566778e-01, 2.48740234e+01],
        [6.09989240e+03, 2.04345087e-01, 2.28705805e+01, 6.13914637e+03,
         3.68593477e-01, 2.04351185e+01],
        [6.10003388e+03, 2.09071809e-01, 2.36486131e+01, 6.14049527e+03,
         6.45122027e-01, 1.70911553e+01],
        [6.10010349e+03, 1.89466088e-01, 2.48062344e+01, 6.13856043e+03,
         2.03697105e-01, 2.88633580e+01],
        [6.10016790e+03, 2.27327424e-01, 2.26736095e+01, 6.13963045e+03,
         2.27999038e-01, 2.64018041e+01],
        [6.10006009e+03, 2.31214299e-01, 2.21791380e+01, 6.13840271e+03,
         3.25316615e-01, 2.69850671e+01],
        [6.09990785e+03, 2.28792426e-01, 2.10575568e+01, 6.13950903e+03,
         2.41015515e-01, 2.49360357e+01],
        [6.10012800e+03, 2.10523150e-01, 2.33187176e+01, 6.13875839e+03,
         3.36356790e-01, 2.50110046e+01],
        [6.10007486e+03, 1.73295207e-01, 2.63964092e+01, 6.13903851e+03,
         4.06048137e-01, 2.42101485e+01],
        [6.09999722e+03, 2.15214580e-01, 2.31928040e+01, 6.13755166e+03,
         3.21831982e-01, 2.32722549e+01],
        [6.10015171e+03, 2.08743365e-01, 2.28937616e+01, 6.13701180e+03,
         5.58262454e-01, 1.80547658e+01],
        [6.09995644e+03, 2.37364633e-01, 2.18123503e+01, 6.13828077e+03,
         4.06664937e-01, 2.46037099e+01],
        [6.09993619e+03, 2.36647632e-01, 2.16407181e+01, 6.13835126e+03,
         5.08063238e-01, 1.92313238e+01],
        [6.09994353e+03, 1.93123359e-01, 2.50249067e+01, 6.13968771e+03,
         2.35238767e-01, 2.01169164e+01],
        [6.10002227e+03, 2.08979719e-01, 2.40292075e+01, 6.13833553e+03,
         2.49358220e-01, 2.15808330e+01],
        [6.09994161e+03, 2.20884706e-01, 2.30602431e+01, 6.13888293e+03,
         2.81310033e-01, 2.20633746e+01],
        [6.10008650e+03, 1.90960995e-01, 2.57558543e+01, 6.14077988e+03,
         2.55777424e-01, 2.68215947e+01],
        [6.09997966e+03, 2.14207912e-01, 2.26176834e+01, 6.14034861e+03,
         2.29810785e-01, 2.71786900e+01],
        [6.10014476e+03, 2.38770434e-01, 2.09836008e+01, 6.13670979e+03,
         3.26039257e-01, 2.54410980e+01],
        [6.09994127e+03, 2.15216138e-01, 2.30670916e+01, 6.13939275e+03,
         2.64765470e-01, 2.48957089e+01],
        [6.10012847e+03, 1.81391870e-01, 2.55715601e+01, 6.13951990e+03,
         4.43634663e-01, 2.17549199e+01],
        [6.09993101e+03, 2.11985093e-01, 2.26677261e+01, 6.13784887e+03,
         3.20077075e-01, 2.75013745e+01],
        [6.10005789e+03, 2.09363086e-01, 2.35096312e+01, 6.13623737e+03,
         3.44478510e-01, 2.17695484e+01],
        [6.10000723e+03, 2.36459931e-01, 2.17864602e+01, 6.13800915e+03,
         4.42251622e-01, 1.70209248e+01],
        [6.10009936e+03, 2.48450564e-01, 2.02625477e+01, 6.13918108e+03,
         4.52738678e-01, 2.50886979e+01],
        [6.10001734e+03, 2.27750904e-01, 2.24345959e+01, 6.13705693e+03,
         3.31189066e-01, 2.08420689e+01],
        [6.10000815e+03, 2.14245889e-01, 2.26423475e+01, 6.13980128e+03,
         3.79317141e-01, 1.90396242e+01],
        [6.10000446e+03, 1.82830359e-01, 2.55476433e+01, 6.13839862e+03,
         2.53254313e-01, 2.50006001e+01],
        [6.09987213e+03, 2.03052242e-01, 2.35032547e+01, 6.13741776e+03,
         6.61326001e-01, 2.10794948e+01],
        [6.09993680e+03, 2.07253023e-01, 2.32245785e+01, 6.14041139e+03,
         2.77728134e-01, 2.22750250e+01],
        [6.10015457e+03, 1.76087248e-01, 2.63676181e+01, 6.13810584e+03,
         2.21701624e-01, 2.71125327e+01],
        [6.09996154e+03, 1.79406383e-01, 2.65044743e+01, 6.13976442e+03,
         3.19608478e-01, 2.27917067e+01],
        [6.10004349e+03, 1.93645983e-01, 2.34742765e+01, 6.13813586e+03,
         4.83721847e-01, 2.27571664e+01],
        [6.09999480e+03, 2.22381274e-01, 2.25304356e+01, 6.13968654e+03,
         3.22210482e-01, 2.20964921e+01],
        [6.10002286e+03, 2.21223051e-01, 2.25435190e+01, 6.13756123e+03,
         2.22785559e-01, 2.66961235e+01],
        [6.10000982e+03, 2.20164048e-01, 2.24126465e+01, 6.13844553e+03,
         1.60940201e-01, 2.86205065e+01],
        [6.10001642e+03, 2.23331102e-01, 2.29133931e+01, 6.13854851e+03,
         3.21511754e-01, 2.38482689e+01],
        [6.10000186e+03, 1.97639738e-01, 2.42030571e+01, 6.13762279e+03,
         2.63408566e-01, 2.42198146e+01],
        [6.09997240e+03, 2.10176414e-01, 2.35927980e+01, 6.13701128e+03,
         3.84788729e-01, 2.07996786e+01],
        [6.09999307e+03, 2.22451761e-01, 2.25042376e+01, 6.13821102e+03,
         2.34890146e-01, 2.90212052e+01],
        [6.10009936e+03, 1.90041073e-01, 2.50571876e+01, 6.13870845e+03,
         3.34308000e-01, 2.20363273e+01],
        [6.10007126e+03, 1.82890523e-01, 2.60980008e+01, 6.13863564e+03,
         2.56716322e-01, 2.54072576e+01],
        [6.10009241e+03, 2.02343022e-01, 2.32508898e+01, 6.13883446e+03,
         2.46425085e-01, 2.36086089e+01],
        [6.10001327e+03, 2.54253087e-01, 2.04223320e+01, 6.13734478e+03,
         2.55013688e-01, 2.77674111e+01],
        [6.09999281e+03, 1.90151591e-01, 2.50037768e+01, 6.13826858e+03,
         4.63432799e-01, 1.64947498e+01],
        [6.09994744e+03, 1.96112885e-01, 2.40874343e+01, 6.13838431e+03,
         3.79968688e-01, 2.21891119e+01],
        [6.10004528e+03, 2.21100521e-01, 2.20989981e+01, 6.13991062e+03,
         2.83230878e-01, 2.78416808e+01],
        [6.09996131e+03, 2.08616386e-01, 2.34229129e+01, 6.13646365e+03,
         4.41338128e-01, 2.00952379e+01],
        [6.10020345e+03, 2.14292036e-01, 2.34789922e+01, 6.13971590e+03,
         2.68906944e-01, 2.45814916e+01],
        [6.10009366e+03, 2.23073573e-01, 2.19419879e+01, 6.13769964e+03,
         4.93519774e-01, 2.30273625e+01],
        [6.09987714e+03, 2.05936097e-01, 2.41334851e+01, 6.13975400e+03,
         6.36564383e-01, 1.87980060e+01],
        [6.09998231e+03, 2.01856373e-01, 2.45073940e+01, 6.14039868e+03,
         2.64492518e-01, 2.21918255e+01],
        [6.10006464e+03, 2.51742108e-01, 2.00592912e+01, 6.13753014e+03,
         2.43580803e-01, 2.66822224e+01],
        [6.10008965e+03, 2.16441294e-01, 2.24216174e+01, 6.13782186e+03,
         4.06313866e-01, 1.90064854e+01],
        [6.10006868e+03, 2.21026654e-01, 2.28010177e+01, 6.13835574e+03,
         3.30729627e-01, 2.16256128e+01],
        [6.10008108e+03, 2.28086995e-01, 2.16192594e+01, 6.14040099e+03,
         4.62651601e-01, 1.66478112e+01],
        [6.09997647e+03, 1.83152574e-01, 2.56946893e+01, 6.13947033e+03,
         3.31478225e-01, 2.79084381e+01],
        [6.09991688e+03, 2.03316655e-01, 2.32088865e+01, 6.13866530e+03,
         6.89404798e-01, 1.95789483e+01],
        [6.10000899e+03, 2.01997014e-01, 2.40565741e+01, 6.13779650e+03,
         3.53222317e-01, 2.32439748e+01],
        [6.09999168e+03, 2.16165707e-01, 2.28836175e+01, 6.13971089e+03,
         3.67842685e-01, 2.31321280e+01],
        [6.10001923e+03, 2.22912152e-01, 2.26602711e+01, 6.13881432e+03,
         4.88379750e-01, 2.05539946e+01],
        [6.09999350e+03, 2.10507307e-01, 2.34034397e+01, 6.13846453e+03,
         3.64455023e-01, 2.66394374e+01],
        [6.10008173e+03, 2.24521309e-01, 2.22118855e+01, 6.13734087e+03,
         5.03794798e-01, 1.64912175e+01],
        [6.09996340e+03, 2.23808313e-01, 2.21493416e+01, 6.13852922e+03,
         3.25775737e-01, 2.34750770e+01],
        [6.10007334e+03, 1.90612892e-01, 2.53044796e+01, 6.13963185e+03,
         3.20403445e-01, 2.21458070e+01],
        [6.10016286e+03, 2.12068692e-01, 2.25623654e+01, 6.14063134e+03,
         2.53507490e-01, 2.33568471e+01],
        [6.09994932e+03, 2.07483279e-01, 2.35482698e+01, 6.13884369e+03,
         4.43202309e-01, 1.82822305e+01],
        [6.09998360e+03, 2.18105375e-01, 2.27169859e+01, 6.13915275e+03,
         2.95907070e-01, 2.20864898e+01],
        [6.10001981e+03, 2.05120487e-01, 2.26722995e+01, 6.13915649e+03,
         6.64407230e-01, 1.61125435e+01],
        [6.10001579e+03, 1.87194035e-01, 2.55455194e+01, 6.13895301e+03,
         3.01152109e-01, 2.27010211e+01]]))
../_images/f1ce39c4350e39a4e6ae72dc8933479cdba937f46ad01ed96a1c772dc1bfff7e.png
######################
# EXERCISE: BinarySourceE8.txt PART: 2
#---------------------
# corner plot
plot_corner(final_state, model1, model2, data)
######################
<Figure size 640x480 with 0 Axes>
../_images/5144b9bbd27061dc8b44f48f17eaab751f3ce23e4360650815517d44566b73b4.png
# Plot the lightcurve
plt.close(8)
plt.figure(8)

######################
# EXERCISE: BinarySourceE8.txt PART: 3
#---------------------
# Your code goes here

# Plot the data
plt.errorbar(time, flux, yerr=flux_err, 
             fmt='x', 
             ecolor='k', 
             capsize=1, 
             color='k', 
             alpha=0.6, 
             zorder=0,
             label='simulated data'
             )

chi2_state = np.zeros(nwalkers)
FS1_state = np.zeros(nwalkers)
FS2_state = np.zeros(nwalkers)
FB_state = np.zeros(nwalkers)
for i in range(nwalkers):
    chi2_state[i], FS1_state[i], FS2_state[i], FB_state[i] = binary_source_chi2(final_state[i], 
                                                                                model1, 
                                                                                model2, 
                                                                                data, 
                                                                                return_fluxes=True
                                                                                )

# find the minimum chi2 sample
best = np.argmin(chi2_state)
theta_best = final_state[best]
print('best fit parameters:', theta_best)

def plot_sample(theta, model1, model2, chi2, FS1, FS2, FB, color='k', alpha=0.1, label='_', zorder=0, ms=1):
    """Plot the model lightcurve"""
    model1.parameters.t_0 = theta[0]
    model1.parameters.u_0 = theta[1]
    model1.parameters.t_E = theta[2]
    model2.parameters.t_0 = theta[3]
    model2.parameters.u_0 = theta[4]
    model2.parameters.t_E = theta[5]

    F_model = model1.get_magnification(T) * FS1 + model2.get_magnification(T) * FS2 + FB

    plt.plot(T, F_model, color=color, linestyle='-', lw=1, alpha=alpha, label=label, zorder=zorder, ms=ms)

# Plot the model
for i in np.linspace(0, nwalkers-1, 10, dtype=int):
    if i==0:
        label = 'samples'
    else:
        label = '_samples'

    plot_sample(final_state[i], 
                model1, 
                model2, 
                chi2_state[i], 
                FS1_state[i], 
                FS2_state[i], 
                FB_state[i], 
                color='b', 
                alpha=0.5, 
                zorder=1, 
                label=label,
                ms=3
                )
    
plot_sample(theta_best, 
            model1, 
            model2, 
            chi2_state[best], 
            FS1_state[best], 
            FS2_state[best], 
            FB_state[best], 
            color='r', 
            label='best fit', 
            zorder=2,
            alpha=1
            )


plt.legend()
plt.ylabel('flux')
plt.xlabel('HJD (days)')
######################

plt.show()
best fit parameters: [6.10003457e+03 2.03537413e-01 2.37170014e+01 6.13887005e+03
 2.62399352e-01 2.59152501e+01]
../_images/9f70b289423486e299a6c04a526fff5cac9338e00c5a71b575e4e7a2745ec113.png

Exercise 9

Which of the microlensing parameters in this model show correlated posteriors?


Any parameters that show diagonal samples in the 2D posteriors of the corner plot.

Binary source events can throw a real spanner in the works when it comes to event modelling. Binary sources complicate what is already a very complicated analysis process and the binary source model is full of correlated parameters that make it hard to determine the true values precisely. Not only can they mimic caustic purturbations with a finite source effect, but they can also mess with colour-colour inference between observatories such as contemporaneous space-based observations, or late-time follow-up lens-flux and astrometric observations. With binaries possibly making up 40% or more of the stars in the Galactic centre (Gautam et al., 2024; McTier, Kipping, & Johnston, 2020), this potential complication is not one that should be ignored.

Having a binary source in a microlensing event can double the number of parameters in our models, which has a dramatic impact on compute time. If we are calling a magnification function for each source, then the compute time roughly doubles for each model evaluation, as the magnification function is usually the most computationally intensive piece of microlensing event-modelling code. Besides which, higher dimensions of parameter space simply take longer to explore.

These real costs are part of the reason for why microlensing simulations, to date, mostly ignore binary sources. Recent work by the PopSyCLE team (Abrams et al., 2025) found that, using their population models, 55% of observed microlensing events involve a binary system;

  • 14.5% wth a single source and muptiple lenses,

  • 31.7% with multiple sources and a single lens, and

  • 8.8% with multiple sources and multiple lenses.

This paper is well worth a read, if you have the time.

Exercise 10

Which binary-lens models do you think would have degenerate, binary-source, counter parts?


Binary-lens models that do not have sharp perturbations from the packzynski curve; i.e., those with a large finite-source effect or without caustic crossings

Recent simulations by Sangtarash & Yee (2025) demonstrated that, for Roman-like cadences, the binary-source/wide-orbit-planet degeneracy is most persistent when $\rho$ is large (which typically occurs in events with small $\theta_{\rm E}$) or $q$ is small, as the likelihoods of the models do not differ significantly in these cases; we can infer that, in general, lower-mass objects make this degeneracy harder to resolve.

As a closing statement for this notebook lets simplify what we have learned to this TLDR:

Beware of the dog sign with dog replace by "binary source".

Next steps#

Well, you've been warned. Now let's move on to something else. Maybe to one of these:

Okay, bye. I'll see you there.