examples.history_match_gradient

The gradient of a production-history misfit wrt. $\log K$, and a few descent steps.

The history-matching case, in its simplest form. A truth permeability field (smoothed, log-normal) produces the observations: the water cut at each of the four producers of a five-spot, at every time step. The prior guess is homogeneous, $\log K = 0$. The objective is the mean squared error of the prior's production history against the observations, and TPFA_ResSim.tlm.adjoint gives its gradient with respect to every cell's $\log K$ -- checked against a finite difference in a random direction -- for about the cost of one more simulation, however many cells there are. That is what makes gradient-based history matching feasible at all, and a few steps of steepest descent (each with a coarse line search) are taken to show it works: the misfit falls, and the update goes the way of the truth.

The seeds are those of a data misfit (ref the "Seeding" section of TPFA_ResSim.tlm): for each observed time and producer, the residual, weighted by the derivative of the observation operator -- here $ f_w'(s) $ in the producer's cell, via tlm.fractional_flow.

In the figure:

  • Top left: the truth $\log K$, which the observations come from.
  • Top middle: the gradient of the misfit at the prior. Since the prior is homogeneous, this is the whole of the first update (with a minus sign): it is negative (raise $K$) where the truth is permeable and the water reaches a producer through it, positive where the truth is tight -- but only where the production data are sensitive, i.e. along the flow paths. Hence its correlation with the truth over the whole field is weak (about 0.2; it is recorded in the digest): the data are blind to most of the field.
  • Top right: the estimate after the descent steps (on its own colour scale: the update is far smaller than the truth's variations). It moves towards the truth along the flow paths and not elsewhere, the data saying nothing about the rest: the classic ill-posedness that a prior (regularization) would address.
  • Bottom: the production histories -- observations, prior, and the final estimate -- and the misfit per iteration.
Steepest descent is used for its simplicity, not its merit.

A Gauss--Newton or quasi-Newton method, and a prior term, would do far better; the point here is the gradient, which any of them needs.

History-match gradient
History-match gradient
  1"""The gradient of a production-history misfit wrt. $\\log K$, and a few descent steps.
  2
  3The history-matching case, in its simplest form. A *truth* permeability field
  4(smoothed, log-normal) produces the *observations*: the water cut at each of
  5the four producers of a five-spot, at every time step. The *prior* guess is
  6homogeneous, $\\log K = 0$. The objective is the mean squared error of the
  7prior's production history against the observations, and
  8`TPFA_ResSim.tlm.adjoint` gives its gradient with respect to every cell's
  9$\\log K$ -- checked against a finite difference in a random direction -- for
 10about the cost of one more simulation, however many cells there are. That is
 11what makes gradient-based history matching feasible at all, and a few steps of
 12steepest descent (each with a coarse line search) are taken to show it works:
 13the misfit falls, and the update goes the way of the truth.
 14
 15The seeds are those of a data misfit
 16(ref the "Seeding" section of `TPFA_ResSim.tlm`): for each observed time and
 17producer, the residual, weighted by the derivative of the observation operator
 18-- here $ f_w'(s) $ in the producer's cell, via `tlm.fractional_flow`.
 19
 20In the figure:
 21
 22- Top left: the truth $\\log K$, which the observations come from.
 23- Top middle: the gradient of the misfit at the prior. Since the prior is
 24  homogeneous, this is the whole of the first update (with a minus sign): it
 25  is negative (raise $K$) where the truth is permeable *and* the water reaches
 26  a producer through it, positive where the truth is tight -- but only where
 27  the production data are sensitive, i.e. along the flow paths. Hence its
 28  correlation with the truth over the whole field is weak (about 0.2; it is
 29  recorded in the digest): the data are blind to most of the field.
 30- Top right: the estimate after the descent steps (on its own colour scale:
 31  the update is far smaller than the truth's variations). It moves towards
 32  the truth along the flow paths and not elsewhere, the data saying nothing
 33  about the rest: the classic ill-posedness that a prior (regularization)
 34  would address.
 35- Bottom: the production histories -- observations, prior, and the final
 36  estimate -- and the misfit per iteration.
 37
 38.. note:: Steepest descent is used for its simplicity, not its merit.
 39
 40    A Gauss--Newton or quasi-Newton method, and a prior term, would do far
 41    better; the point here is the *gradient*, which any of them needs.
 42"""
 43
 44from mpl_tools.place import freshfig
 45import numpy as np
 46from scipy.ndimage import uniform_filter as smooth
 47
 48from TPFA_ResSim import ResSim
 49from TPFA_ResSim.plotting import show
 50from TPFA_ResSim.tlm import adjoint, fractional_flow
 51
 52rng = np.random.default_rng(1)  # Reproducibility (the values are regression tested)
 53
 54## Model: a five-spot
 55wells = [
 56    dict(xy=[.5, .5], rate=+1  , name="inj"),
 57    dict(xy=[1 , 1 ], rate=-.25, name="NE"),
 58    dict(xy=[0 , 1 ], rate=-.25, name="NW"),
 59    dict(xy=[0 , 0 ], rate=-.25, name="SW"),
 60    dict(xy=[1 , 0 ], rate=-.25, name="SE"),
 61]
 62grid: dict = dict(Lx=1, Ly=1, Nx=32, Ny=32)
 63dt, nSteps = .05, 30
 64
 65
 66def new_model(logK):
 67    model = ResSim(**grid, wells=wells)
 68    model.K = np.exp(logK)  # isotropic: broadcast to both components
 69    return model
 70
 71
 72model = new_model(0)  # the prior: homogeneous
 73S0 = np.zeros(model.Nxy)
 74producers = model.wells.names[1:]
 75prd = model.xy2ind(*model.wells.xy[1:].T)  # their cells
 76
 77
 78def water_cut(model, SS):
 79    """`(nSteps, nPrd)` water cut at each producer, at times `1..nSteps`."""
 80    return np.array([fractional_flow(model, S)[0][prd] for S in SS[1:]])
 81
 82
 83## The truth, and the observations it produces
 84logK_true = 3 * smooth(smooth(rng.standard_normal(model.shape)))
 85truth = new_model(logK_true)
 86obs = water_cut(truth, truth.sim(dt, nSteps, S0, pbar=False)[0])
 87
 88
 89## The objective, and its gradient by the adjoint
 90def misfit(logK, gradient=False):
 91    """Mean squared error of the production history of `logK` against `obs`."""
 92    model = new_model(logK)
 93    SS, PP = model.sim(dt, nSteps, S0, pbar=False)
 94    fw = water_cut(model, SS)
 95    residual = fw - obs
 96    J = (residual**2).mean()
 97    if not gradient:
 98        return J
 99    # Seed: ∂J/∂s_k[i] = 2/nObs * residual * f_w'(s), at the producers, each time
100    dJ_dSS = np.zeros_like(SS)
101    for k in range(1, nSteps + 1):
102        dJ_dSS[k, prd] = 2 / obs.size * residual[k - 1] * fractional_flow(model, SS[k])[1][prd]
103    G = adjoint(model, dt, SS, PP, dJ_dSS).logK.sum(0)  # isotropic ⇒ sum the components
104    return J, G, fw
105
106
107logK = np.zeros(model.shape)  # the prior
108J0, G0, fw_prior = misfit(logK, gradient=True)
109
110## Check: a finite difference in a random direction of log K
111direction = rng.standard_normal(model.shape)
112eps = 1e-5
113fd = (misfit(logK + eps*direction) - misfit(logK - eps*direction)) / (2*eps)
114directional = (G0 * direction).sum()
115assert abs(fd - directional) < 1e-4 * abs(directional), (fd, directional)
116
117## A few steps of steepest descent, each with a coarse line search
118nIter = 4
119step_sizes = [.1, .2, .4, .8]  # in units of max |Δ log K| per step
120JJ = [J0]
121J, G = J0, G0
122for _ in range(nIter):
123    d = -G / abs(G).max()  # the (normalized) descent direction
124    trials = [misfit(logK + a*d) for a in step_sizes]
125    best = int(np.argmin(trials))
126    if trials[best] >= J:
127        break  # no step size improves: stop
128    logK = logK + step_sizes[best] * d
129    J, G, fw = misfit(logK, gradient=True)
130    JJ.append(J)
131fw_final = water_cut(new_model(logK), new_model(logK).sim(dt, nSteps, S0, pbar=False)[0])
132corr = np.corrcoef(-G0.ravel(), logK_true.ravel())[0, 1]
133
134## Plot
135fig, axs = freshfig("History-match gradient", ncols=3, nrows=2, figsize=(13, 8))
136
137kws = dict(cmap="viridis", wells="color", finalize=False)
138model.plt_field(axs[0, 0], logK_true, title="Truth, $\\log K$",
139                levels=np.linspace(-3, 3, 19), cticks=np.arange(-3, 4), **kws)
140m = abs(logK).max()  # NB: its own scale -- the update is far smaller than the truth
141model.plt_field(axs[0, 2], logK, title=f"Estimate, after {len(JJ) - 1} descent steps",
142                levels=np.linspace(-m, m, 19), cticks=[-m, 0, m], **kws)
143m = abs(G0).max()
144model.plt_field(axs[0, 1], G0, title="$∂J/∂\\log K$ at the prior ($\\log K = 0$)",
145                cmap="RdBu_r", levels=np.linspace(-m, m, 21), cticks=[-m, 0, m],
146                wells="color", finalize=False)
147axs[0, 1].text(.02, .02, f"corr(−∂J/∂log K, truth) = {corr:.2f}",
148               transform=axs[0, 1].transAxes, fontsize=8)
149
150tt = dt * np.arange(1, nSteps + 1)
151for ax, fw, title in [(axs[1, 0], fw_prior, "prior"), (axs[1, 1], fw_final, "estimate")]:
152    for i, name in enumerate(producers):
153        ax.plot(tt, obs[:, i], "*", c=f"C{i}", label=f"{name} obs.")
154        ax.plot(tt, fw[:, i], "-", c=f"C{i}", label=f"{name} {title}")
155    ax.set(title=f"Production history: {title}", xlabel="Time", ylabel="Water cut",
156           ylim=(-.02, 1))
157    ax.legend(loc="upper left", ncol=2, fontsize=8)
158
159ax = axs[1, 2]
160ax.semilogy(JJ, "o-")
161ax.set(title="Misfit (MSE)", xlabel="Iteration")
162ax.xaxis.get_major_locator().set_params(integer=True)
163
164fig.tight_layout()
165
166# Regression values, checked by `tests/test_examples.py`.
167__digest__ = dict(misfit      = JJ,
168                  gradient    = G0,
169                  directional = [directional, fd],
170                  corr        = [corr],
171                  logK_final  = logK)
172
173if __name__ == "__main__":
174    show()