Skip to article frontmatterSkip to article content

2.5D DC Resistivity Inversion

Authors
Affiliations
University of British Columbia
University of British Columbia

Author: Devin C. Cowan

Keywords: DC resistivity, 2.5D inversion, sparse norm, tree mesh.

Summary: Here we invert DC resistivity data on a tree mesh to recover the subsurface distribution of electric properties. To demonstrate a range of functionality within SimPEG, we apply two approaches:

  1. Weighted least-squares inversion, where we invert normalized voltage data to recover a log-conductivity model
  2. Iteratively re-weighted least-squares (IRLS) inversion, where we invert apparent resistivity data to recover a log-resistivity model

The weighted least-squares approach is a great introduction to geophysical inversion with SimPEG. One drawback however, is that it recovers smooth structures which may not be representative of the true model. The iteratively re-weighted least-squares approach is able to recover sparse and/or blocky structures. Because this tutorial focusses primarily on inversion-related functionality, we urge the reader to become familiar with functionality explained in the 2.5D Forward Simulation tutorial before working through this one.

Learning Objectives:

  • Introduce geophysical inversion with SimPEG.
  • Assigning appropriate uncertainties to normalized voltage and apparent resistivity data.
  • Designing a suitable mesh for 2.5D DC resistivity inversion.
  • Choosing suitable parameters for the inversion.
  • Specifying directives that are applied throughout the inversion.
  • Apply the sensitivity weighting commonly used when inverting magnetic data.
  • Inversion with weighted least-squares and sparse-norm regularizations.
  • Analyzing inversion outputs.

Import Modules

Here, we import all of the functionality required to run the notebook for the tutorial exercise. All of the functionality specific to DC resistivity is imported from simpeg.electromagnetics.static.resistivity. We also import some useful utility functions from simpeg.utils. Classes required to define the data misfit, regularization, optimization, etc... are imported from elsewhere within SimPEG. We also import some useful utility functions from simpeg.utils. To generate the mesh used for the inversion, we use the discretize package.

# SimPEG functionality
from simpeg.electromagnetics.static import resistivity as dc
from simpeg.electromagnetics.static.utils.static_utils import (
    plot_pseudosection,
    generate_survey_from_abmn_locations,
    apparent_resistivity_from_voltage,
    geometric_factor,
)
from simpeg.utils.io_utils.io_utils_electromagnetics import read_dcip2d_ubc
from simpeg.utils import download, model_builder
from simpeg import (
    maps,
    data,
    data_misfit,
    regularization,
    optimization,
    inverse_problem,
    inversion,
    directives,
)

# discretize functionality
from discretize import TreeMesh
from discretize.utils import mkvc, active_from_xyz

# Basic Python functionality
import os
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
from scipy.interpolate import LinearNDInterpolator
import tarfile

mpl.rcParams.update({"font.size": 14})  # default font size
cmap = mpl.cm.RdYlBu_r  # default colormap

Load Tutorial Data and Plot

For most geophysical inversion projects, a reasonable inversion result can be obtained so long as the practitioner has observed data, the survey geometry and topography. For this tutorial, the observed data and topography files are provided. Here, we download and import the observed data and topography into the SimPEG framework.

# URL to download from repository assets
data_source = "https://github.com/simpeg/user-tutorials/raw/main/assets/05-dcr/inv_dcr_2d_files.tar.gz"

# download the data
downloaded_data = download(data_source, overwrite=True)

# unzip the tarfile
tar = tarfile.open(downloaded_data, "r")
tar.extractall()
tar.close()

# path to the directory containing our data
dir_path = downloaded_data.split(".")[0] + os.path.sep

# files to work with
topo_filename = dir_path + "topo_2d.txt"
data_filename = dir_path + "dc_data.obs"
Downloading https://github.com/simpeg/user-tutorials/raw/main/assets/05-dcr/inv_dcr_2d_files.tar.gz
   saved to: /home/ssoler/git/user-tutorials/notebooks/05-dcr/inv_dcr_2d_files.tar.gz
Download completed!

Load the Topography

True surface topography is defined as an (N, 3) numpy.ndarray. For the 2.5D problem geometry however, topography is an (N, 2) numpy.ndarray, where the first coordinate represent along-line position and the second coordinate represents the vertical position. In this tutorial, we assume the topography and electrode locations are defined according to the 2.5D geometry.

# Load 2D topography
topo_2d = np.loadtxt(str(topo_filename))
# Plot 2D topography
fig = plt.figure(figsize=(10, 2))
ax = fig.add_axes([0.1, 0.1, 0.8, 0.8])
ax.plot(topo_2d[:, 0], topo_2d[:, -1], color="b", linewidth=1)
ax.set_xlim([topo_2d[:, 0].min(), topo_2d[:, 0].max()])
ax.set_xlabel("x (m)", labelpad=5)
ax.set_ylabel("z (m)", labelpad=5)
ax.grid(True)
ax.set_title("Topography (Exaggerated z-axis)", fontsize=16, pad=10)
plt.show(fig)
<Figure size 1000x200 with 1 Axes>

Load DC Resistivity Data

Option A: DCIP2D formatted data

For this tutorial, the observed data are organized with a UBC-GIF DCIP2D formatted data file. We can use the read_dcip2d_ubc utility function to load data in this format. This function outputs a SimPEG Data object. The data are normalized voltages in units V/A.

voltage_data = read_dcip2d_ubc(data_filename, "volt", "general")

Option B: Survey from ABMN electrode locations

If you have CSV-formatted data containing the ABMN electrode locations, you will need to:

E.g. for a file containing electrode locations already formatted for a 2.5D geometry:

data_array = np.loadtxt(data_filename, skiprows=1)

dobs = data_array[:, -1]
A = data_array[:, 0:2]
B = data_array[:, 2:4]
M = data_array[:, 4:6]
N = data_array[:, 6:8]

survey = generate_survey_from_abmn_locations(
    locations_a=A,
    locations_b=B,
    locations_m=M,
    locations_n=M,
    data_type='volt'
)

dc_data = data.Data(survey, dobs=dobs)

Plot DC Resistivity Data in Pseudo-Section

Here we use the plot_pseudosection utility function to plot the normalized voltage data in pseudosection. We also use the apparent_resistivity_from_voltage utility function to convert the data to apparent resistivities, which are also plotted in pseudosection.

# Plot voltages pseudo-section
fig = plt.figure(figsize=(8, 2.75))
ax1 = fig.add_axes([0.1, 0.15, 0.75, 0.78])
plot_pseudosection(
    voltage_data,
    plot_type="scatter",
    ax=ax1,
    scale="log",
    cbar_label="V/A",
    scatter_opts={"cmap": mpl.cm.viridis},
)
ax1.set_title("Normalized Voltages")
plt.show()

# Get apparent conductivities from volts and survey geometry
apparent_resistivities = apparent_resistivity_from_voltage(
    voltage_data.survey, voltage_data.dobs
)

# Plot apparent resistivity pseudo-section
fig = plt.figure(figsize=(8, 2.75))
ax1 = fig.add_axes([0.1, 0.15, 0.75, 0.78])
plot_pseudosection(
    voltage_data.survey,
    apparent_resistivities,
    plot_type="contourf",
    ax=ax1,
    scale="log",
    cbar_label="$\Omega m$",
    mask_topography=True,
    contourf_opts={"levels": 20, "cmap": mpl.cm.RdYlBu},
)
ax1.set_title("Apparent Resistivity")
plt.show()
<Figure size 800x275 with 2 Axes><Figure size 800x275 with 2 Axes>

Assign Uncertainties

Inversion with SimPEG requires that we define the uncertainties on our data; that is, an estimate of the standard deviation of the noise on our data assuming it is uncorrelated Gaussian with zero mean. An online resource explaining uncertainties and their role in the inversion can be found here.

For normalized voltage data, we generally apply a percent uncertainty and a very small floor uncertainty to all data. Differences in electrode spacing and subsurface conductivity result in measured voltages that span many orders of magnitude. A percent uncertainty ensures all data are fit equally. Depending on the quality of the data, we may choose a percent uncertainty between 2% - 10%. The floor uncertainty ensures stability when there are zero-crossings or erroneously small voltages. Here, we apply uncertainties of 1e-7 V/A + 5 %.

For apparent resistivity data, we also apply a percent uncertainty and a very small floor uncertainty to all data. A percent uncertainty will fit conductive and resistive structures equally; unlike a floor uncertainty which will prioritize fitting more resistive structures. Depending on the quality of the data, we may choose a percent uncertainty between 2% - 10%. The floor uncertainty ensures stability when there are zero-crossings or erroneously small voltages.

# Apply uncertainties to normalized voltage data
voltage_data.standard_deviation = 1e-7 + 0.05 * np.abs(voltage_data.dobs)

Design a (Tree) Mesh

The same rules for defining appropriate meshes for forward simulation of DC resistivity apply to inversion. Please visit the 2.5D Forward Simulation tutorial to see the best practices for mesh design.

Tutorial mesh: Here, a minimum cell width of 4 m (or 1/10 the minimum electrode spacing) is used within our survey region. The largest electrode spacing was 400 m, so a the padding was extended at least 1200 m from the survey region. Using the refine_surface method, we refine the tree mesh where there is significant topography. And using the refine_points methods, we refine based on electrodes locations. Visit the tree mesh API to see additional refinement methods.

dh = 4  # base cell width
dom_width_x = 3200.0  # domain width x
dom_width_z = 2400.0  # domain width z
nbcx = 2 ** int(np.round(np.log(dom_width_x / dh) / np.log(2.0)))  # num. base cells x
nbcz = 2 ** int(np.round(np.log(dom_width_z / dh) / np.log(2.0)))  # num. base cells z

# Define the base mesh with top at z = 0 m
hx = [(dh, nbcx)]
hz = [(dh, nbcz)]
mesh = TreeMesh([hx, hz], x0="CN")

# Shift top to maximum topography
mesh.origin = mesh.origin + np.r_[0.0, topo_2d[:, -1].max()]

# Mesh refinement based on topography
mesh.refine_surface(
    topo_2d,
    padding_cells_by_level=[0, 0, 4, 4],
    finalize=False,
)

# Extract unique electrode locations.
unique_locations = voltage_data.survey.unique_electrode_locations

# Mesh refinement near electrodes.
mesh.refine_points(
    unique_locations, padding_cells_by_level=[8, 12, 6, 6], finalize=False
)

mesh.finalize()

Define the Active Cells

Simulated geophysical data are dependent on the subsurface distribution of physical property values. As a result, the cells lying below the surface topography are commonly referred to as ‘active cells’. And air cells, whose physical property values are fixed, are commonly referred to as ‘inactive cells’. Here, the discretize active_from_xyz utility function is used to find the indices of the active cells using the mesh and surface topography. The output quantity is a bool array.

# Indices of the active mesh cells from topography (e.g. cells below surface)
active_cells = active_from_xyz(mesh, topo_2d)

# number of active cells
n_active = np.sum(active_cells)

Project Electrodes to Discretized Topography

Surface DC resistivity data will not be modeled accurately if the electrodes are modeled as living above or below the surface. It is especially problematic when electrodes are modeled as living in the air. Prior to inverting DC resistivity data, we must project the electrodes from their true elevation to the surface of the discretized topography. This is done using the drape_electrodes_on_topography method.

voltage_data.survey.drape_electrodes_on_topography(mesh, active_cells, option="top")

Weighted Least-Squares Inversion

Here, a weighted least-squares inversion approach is used to invert normalized voltage data to recover a log-conductivity model. We invert for log-conductivity because

  1. It naturally enforces positive conductivity values in the recovered model
  2. Electrical conductivity is a physical property that spans many orders of magnitude

Mapping from the Model to the Mesh

In SimPEG, the term ‘model’ is not synonymous with the physical property values defined on the mesh. For whatever model we choose, we must define a mapping from the set of model parameters (a 1D numpy.ndarray) to the physical property values of all cells in the mesh. Mappings are created using the simpeg.maps module.

For DC resistivity, the model parameters define the subsurface electrical conductivity, and the electrical conductivities of the cells in the air are given a fixed value. Here, the model defines the log-conductivity values for all active cells. We use the simpeg.maps.ExpMap to map from the model parameters to the conductivity values for all active cells. Then we use the simpeg.maps.InjectActiveCells map to project the active cell conductivities to the entire mesh. As explained in the 2.5D Forward Simulation tutorial, air cells are given a fixed value of 1e-8 S/m to ensure the forward problem is well-conditionned. And the * operator is used to combine the separate mapping objects into a single mapping.

# Map model parameters to all cells
log_conductivity_map = maps.InjectActiveCells(mesh, active_cells, 1e-8) * maps.ExpMap(
    nP=n_active
)

Starting/Reference Models

The starting model defines a reasonable starting point for the inversion and does not necessarily represent an initial estimate of the true model. It should be noted that the starting model cannot be vector of zeros, otherwise the inversion will be unable to compute a gradient direction at the first iteration. For DC resistivity inversion, the starting model is frequently a halfspace estimated from the set of apparent resistivities/conductivities.

The reference model is used to include a-priori information. By default, the starting model is set as the reference model. The impact of the reference model on the inversion will be discussed in another tutorial.

Notice that the length of the starting and reference models is equal to the number of model parameters!!!

# Median apparent resistivity
median_resistivity = np.median(apparent_resistivities)

# Create starting model from log-conductivity
starting_conductivity_model = np.log(1 / median_resistivity) * np.ones(n_active)

# Zero reference conductivity model
reference_conductivity_model = starting_conductivity_model.copy()

Define the Forward Simulation

A simulation object defining the forward problem is required in order to predict data and calculate misfits for recovered models. A comprehensive description of the simulation object for 2.5D DC resistivity was discussed in the 2.5D Forward Simulation tutorial. Here, we use the Simulation2DNodal simulation which solves for the electric potential on mesh nodes.

Since our model consists of log-conductivities, we use sigmaMap to set the mapping from the model to the mesh cells. Except for in extreme cases, we can compute and store the Jacobian explicitly for 2.5D problems. By doing so, we drastically reduce the run-time of the inversion.

voltage_simulation = dc.simulation_2d.Simulation2DNodal(
    mesh, survey=voltage_data.survey, sigmaMap=log_conductivity_map, storeJ=True
)

Define the Data Misfit

To understand the role of the data misfit in the inversion, please visit this online resource. Here, we use the L2DataMisfit class to define the data misfit. In this case, the data misfit is the L2 norm of the weighted residual between the observed data and the data predicted for a given model. When instantiating the data misfit object within SimPEG, we must assign an appropriate data object and simulation object as properties.

dmis_L2 = data_misfit.L2DataMisfit(simulation=voltage_simulation, data=voltage_data)

Define the Regularization

To understand the role of the regularization in the inversion, please visit this online resource. Here, we use the WeightedLeastSquares regularization class to constrain the inversion result. Here, the scaling constants that balance the smallness and smoothness terms are set directly. We use the inverse square of the smallest cell size to balance the smallness and smoothness terms. Setting alpha_s to a very small value will recover the smoothest model. And the reference model is only applied to the smallness term.

By default, the regularization acts on the model parameters; which in the case are the log-conductivities of the active cells. So we need to specify which cells are active in the regularization. And if we wanted to apply the regularization to a function of the model parameters, we would need to set an approprate mapping object using the mapping keyword argument.

reg_L2 = regularization.WeightedLeastSquares(
    mesh,
    active_cells=active_cells,
    alpha_s=dh**-2,
    alpha_x=1,
    alpha_y=1,
    reference_model=reference_conductivity_model,
    reference_model_in_smooth=False,
)

Define the Optimization Algorithm

Here, we use the InexactGaussNewton class to solve the optimization problem using the inexact Gauss Newton with conjugate gradient solver. Reasonable default values have generally been set for the properties of each optimization class. However, the user may choose to set custom values; e.g. the accuracy tolerance for the conjugate gradient solver or the number of line searches.

opt_L2 = optimization.InexactGaussNewton(
    maxIter=40, maxIterLS=20, maxIterCG=20, tolCG=1e-3
)

Define the Inverse Problem

We use the BaseInvProblem class to fully define the inverse problem that is solved at each beta (trade-off parameter) iteration. The inverse problem requires appropriate data misfit, regularization and optimization objects.

inv_prob_L2 = inverse_problem.BaseInvProblem(dmis_L2, reg_L2, opt_L2)

Provide Inversion Directives

Directives represent operations that are carried out during the inversion. Here, we apply common directives for weighted least-squares inversion of DC resistivity data and describe their roles. These are:

  • UpdateSensitivityWeights: Apply sensitivity weighting to counteract the natural tendency of DC resistivity inversion to place materials near the electrodes. Since the problem is non-linear and the sensitivities are updated with every model, we set every_iteration=True. For DC resistivity inversion, we do not want to use the entire dynamic range of the sensitivities to generate our weighting. So we generally set threshold_value to a value betwewen 1e-5 and 1e-3.

  • UpdatePreconditioner: Apply Jacobi preconditioner when solving optimization problem to reduce the number of conjugate gradient iterations. We set update_every_iteration=True because the ideal preconditioner is model-dependent.

  • BetaEstimate_ByEig: Compute and set starting trade-off parameter (beta) based on largest eigenvalues.

  • BetaSchedule: Size reduction of the trade-off parameter at every beta iteration, and the number of Gauss-Newton iterations for each beta. In general, a coolingFactor between 1.5 and 2.5, and coolingRate of 2 or 3 works well for DC resistivity inversion. Cooling beta too quickly will result in portions of the model getting trapped in local minima. And we will not be finding the solution that minimizes the optimization problem if the cooling rate is too small.

  • TargetMisfit: Terminates the inversion when the data misfit equals the target misfit. A chifact=1 terminates the inversion when the data misfit equals the number of data.

The directive objects are organized in a list. Upon starting the inversion or updating the recovered model at each iteration, the inversion will call each directive within the list in order. The order of the directives matters, and SimPEG will throw an error if directives are organized into an improper order. Some directives, like the BetaEstimate_ByEig are only used when starting the inversion. Other directives, like UpdatePreconditionner, are used whenever the model is updated.

sensitivity_weights = directives.UpdateSensitivityWeights(
    every_iteration=True, threshold_value=1e-2
)
update_jacobi = directives.UpdatePreconditioner(update_every_iteration=True)
starting_beta = directives.BetaEstimate_ByEig(beta0_ratio=10)
beta_schedule = directives.BetaSchedule(coolingFactor=2.0, coolingRate=2)
target_misfit = directives.TargetMisfit(chifact=1.0)

directives_list_L2 = [
    sensitivity_weights,
    update_jacobi,
    starting_beta,
    beta_schedule,
    target_misfit,
]

Define and Run the Inversion

We define the inversion using the BaseInversion class. The inversion class must be instantiated with an appropriate inverse problem object and directives list. The run method, along with a starting model, is respondible for running the inversion. The output is a 1D numpy.ndarray containing the recovered model parameters

# Here we combine the inverse problem and the set of directives
inv_L2 = inversion.BaseInversion(inv_prob_L2, directives_list_L2)

# Run the inversion
# recovered_model_L2 = inv_L2.run(np.log(0.01) * np.ones(n_param))
recovered_log_conductivity_model = inv_L2.run(starting_conductivity_model)

Running inversion with SimPEG v0.22.1

                        simpeg.InvProblem is setting bfgsH0 to the inverse of the eval2Deriv.
                        ***Done using same Solver, and solver_opts as the Simulation2DNodal problem***
                        
/home/ssoler/mambaforge/envs/simpeg-user-tutorials/lib/python3.10/site-packages/simpeg/electromagnetics/static/resistivity/simulation_2d.py:756: RuntimeWarning: invalid value encountered in divide
  r_hat = r_vec / r[:, None]
/home/ssoler/mambaforge/envs/simpeg-user-tutorials/lib/python3.10/site-packages/simpeg/electromagnetics/static/resistivity/simulation_2d.py:783: RuntimeWarning: invalid value encountered in divide
  alpha[not_top] = (ky * k1e(ky * r) / k0e(ky * r) * r_dot_n)[not_top]
model has any nan: 0
============================ Inexact Gauss Newton ============================
  #     beta     phi_d     phi_m       f      |proj(x-g)-x|  LS    Comment   
-----------------------------------------------------------------------------
x0 has any nan: 0
   0  2.85e+01  4.45e+04  0.00e+00  4.45e+04    5.33e+03      0              
/home/ssoler/mambaforge/envs/simpeg-user-tutorials/lib/python3.10/site-packages/simpeg/optimization.py:1072: UserWarning: tol is not a valid keyword for cg and will be ignored
  Hinv = SolverICG(
   1  2.85e+01  1.79e+03  5.85e+01  3.46e+03    4.18e+02      0              
   2  1.43e+01  5.14e+02  7.73e+01  1.62e+03    8.86e+01      0   Skip BFGS  
   3  1.43e+01  2.57e+02  8.81e+01  1.51e+03    8.27e+00      0   Skip BFGS  
   4  7.13e+00  2.60e+02  8.84e+01  8.89e+02    5.08e+01      0              
   5  7.13e+00  1.52e+02  9.48e+01  8.28e+02    4.64e+00      0              
   6  3.56e+00  1.51e+02  9.53e+01  4.91e+02    2.60e+01      0              
------------------------- STOP! -------------------------
1 : |fc-fOld| = 0.0000e+00 <= tolF*(1+|f0|) = 4.4540e+03
1 : |xc-x_last| = 2.6844e+00 <= tolX*(1+|x0|) = 3.8957e+01
0 : |proj(x-g)-x|    = 2.5964e+01 <= tolG          = 1.0000e-01
0 : |proj(x-g)-x|    = 2.5964e+01 <= 1e3*eps       = 1.0000e-02
0 : maxIter   =      40    <= iter          =      7
------------------------- DONE! -------------------------

Plot the Data Misfit

This step is necessary for determining whether the recovered model accurately reproduces observed anomalies. Here, we plot the observed data, predicted data for the recovered model, and the normalized data misfit. As we can see, the recovered model reproduces the observed data quite well. And the normalized misfit map indicates we are not overfitting certain electrodes relative to others.

If desired, one could also use the apparent_resistivity_from_voltage utility function compare observed and predicted data as apparent resistivities.

# Predicted data from recovered model
dpred = inv_prob_L2.dpred
dobs = voltage_data.dobs
std = voltage_data.standard_deviation

# Plot
fig = plt.figure(figsize=(9, 11))
data_array = [np.abs(dobs), np.abs(dpred), (dobs - dpred) / std]
plot_title = ["Observed Voltage", "Predicted Voltage", "Normalized Misfit"]
plot_units = ["V/A", "V/A", ""]
scale = ["log", "log", "linear"]
cmap_list = [mpl.cm.viridis, mpl.cm.viridis, mpl.cm.RdYlBu]

ax1 = 3 * [None]
cax1 = 3 * [None]
cbar = 3 * [None]
cplot = 3 * [None]

for ii in range(0, 3):
    ax1[ii] = fig.add_axes([0.15, 0.72 - 0.33 * ii, 0.65, 0.21])
    cax1[ii] = fig.add_axes([0.81, 0.72 - 0.33 * ii, 0.03, 0.21])
    cplot[ii] = plot_pseudosection(
        voltage_data.survey,
        data_array[ii],
        "contourf",
        ax=ax1[ii],
        cax=cax1[ii],
        scale=scale[ii],
        cbar_label=plot_units[ii],
        mask_topography=True,
        contourf_opts={"levels": 25, "cmap": cmap_list[ii]},
    )
    ax1[ii].set_title(plot_title[ii])

plt.show()
<Figure size 900x1100 with 6 Axes>

Plot the Recovered Model

As we can see, weighted least-squares regularization leads to the recovery of smooth models.

# Convert log-conductivity values to conductivity values
recovered_conductivity_L2 = np.exp(recovered_log_conductivity_model)
# Define a mapping to plot models and ignore inactive cells
plotting_map = maps.InjectActiveCells(mesh, active_cells, np.nan)
norm = LogNorm(vmin=5e-4, vmax=2e-1)

fig = plt.figure(figsize=(9, 4))

ax1 = fig.add_axes([0.14, 0.17, 0.68, 0.7])
mesh.plot_image(
    plotting_map * recovered_conductivity_L2,
    normal="Y",
    ax=ax1,
    grid=False,
    pcolor_opts={"norm": norm, "cmap": mpl.cm.RdYlBu_r},
)
ax1.set_xlim(-500, 500)
ax1.set_ylim(-300, 200)
ax1.set_title("Recovered Conductivity Model (L2)")
ax1.set_xlabel("x (m)")
ax1.set_ylabel("z (m)")

ax2 = fig.add_axes([0.84, 0.17, 0.03, 0.7])
cbar = mpl.colorbar.ColorbarBase(
    ax2, norm=norm, orientation="vertical", cmap=mpl.cm.RdYlBu_r
)
cbar.set_label(r"$\sigma$ (S/m)", rotation=270, labelpad=15, size=12)

plt.show()
<Figure size 900x400 with 2 Axes>

Iteratively Re-weighted Least-Squares Inversion

Here, we provide a step-by-step best-practices approach for IRLS inversion of apparent resistivity data on a tree mesh to recover a log-resistivity model. Many of the steps are the same as our previous approach. As a result, we will avoid repeating information whenever possible.

Define An Apparent Resistivity Survey

We cannot reuse a survey that was used to simulate a different data type. So we must define a new survey object for inverting apparent resistivity data. This can be done by extracting the ABMN electrode locations from the previous survey object and using the generate_survey_from_abmn_locations utility function.

# Extract ABMN electrode locations from previous survey object
locations_a = voltage_data.survey.locations_a.copy()
locations_b = voltage_data.survey.locations_b.copy()
locations_m = voltage_data.survey.locations_m.copy()
locations_n = voltage_data.survey.locations_n.copy()

# Define survey from ABMN locations
resistivity_survey = generate_survey_from_abmn_locations(
    locations_a=locations_a,
    locations_b=locations_b,
    locations_m=locations_m,
    locations_n=locations_n,
    data_type="apparent_resistivity",
)

# Set geometric factor for survey (to be handled internally in future versions)
resistivity_survey.set_geometric_factor()
<simpeg.data.Data at 0x7ff0e015fbe0>

Define the Data

resistivity_data = data.Data(survey=resistivity_survey, dobs=apparent_resistivities)

Assign Uncertainties

Uncertainties for apparent resistivity data were discussed earlier. Here we apply uncertainties of 1e-3 Ωm\Omega m + 5 % to all data.

resistivity_data.standard_deviation = 1e-3 + 0.05 * np.abs(resistivity_data.dobs)

Mapping from the Model to the Mesh

Here, the model defines the log-resistivity values for all active cells. We use the simpeg.maps.ExpMap to map from the model parameters to the resistivity values for all active cells. Then we use the simpeg.maps.InjectActiveCells map to project the active cell resisitivities to the entire mesh. As explained in the 2.5D Forward Simulation tutorial, air cells are given a fixed value of 1e8 Ωm\Omega m to ensure the forward problem is well-conditionned. And the * operator is used to combine the separate mapping objects into a single mapping.

log_resistivity_map = maps.InjectActiveCells(mesh, active_cells, 1e8) * maps.ExpMap(
    nP=n_active
)

Starting/Reference Models

The starting model is defined according to the median apparent resistivity value. The reference model is equal to the starting model.

# Create starting model from log-resistivities
starting_resistivity_model = np.log(median_resistivity) * np.ones(n_active)

# Zero reference model
reference_resistivity_model = starting_resistivity_model.copy()

Define the Forward Simulation

resistivity_simulation = dc.simulation_2d.Simulation2DNodal(
    mesh, survey=resistivity_data.survey, rhoMap=log_resistivity_map, storeJ=True
)

Define the Data Misfit

dmis_irls = data_misfit.L2DataMisfit(
    simulation=resistivity_simulation, data=resistivity_data
)

Define the Regularization

Here, we use the Sparse regularization class to constrain the inversion result using an IRLS approach. Length scales along x and y are used to balance the smallness and smoothness terms. Here, length scales of 100 are used to more strongly emphasize smoothness in the recovered model. The reference model is only applied to the smallness term; which is redundant for the tutorial example since we have set the reference model to an array of zeros. Here, we apply a 0-norm to the smallness and 2-norm to first-order smoothness along the x and y directions.

reg_irls = regularization.Sparse(
    mesh,
    active_cells=active_cells,
    length_scale_x=5.0,
    length_scale_y=5.0,
    norms=[0, 2, 2],
    reference_model=reference_resistivity_model,
)

Define the Optimization Algorithm

opt_irls = optimization.InexactGaussNewton(
    maxIter=50, maxIterLS=20, maxIterCG=20, tolCG=1e-3
)

Define the Inverse Problem

inv_prob_irls = inverse_problem.BaseInvProblem(dmis_irls, reg_irls, opt_irls)

Provide Inversion Directives

Here, we create common directives for IRLS inversion of total magnetic intensity data and describe their roles. In additon to the UpdateSensitivityWeights, UpdatePreconditioner and BetaEstimate_ByEig (described before), inversion with sparse-norms requires the Update_IRLS directive.

You will notice that we don’t use the BetaSchedule and TargetMisfit directives. Here, the beta cooling schedule is set in the Update_IRLS directive using the coolingFactor and coolingRate properties. The target misfit for the L2 portion of the IRLS approach is set with the chifact_start property.

sensitivity_weights_irls = directives.UpdateSensitivityWeights(
    every_iteration=True, threshold_value=1e-2
)
update_irls = directives.Update_IRLS(
    coolingFactor=2,
    coolingRate=2,
    f_min_change=1e-4,
    max_irls_iterations=30,
    chifact_start=1.0,
)
starting_beta_irls = directives.BetaEstimate_ByEig(beta0_ratio=10)
update_jacobi_irls = directives.UpdatePreconditioner(update_every_iteration=True)

directives_list_irls = [
    update_irls,
    sensitivity_weights_irls,
    starting_beta_irls,
    update_jacobi_irls,
]

Define and Run the Inversion

inv_irls = inversion.BaseInversion(inv_prob_irls, directives_list_irls)
recovered_log_resistivity_model = inv_irls.run(starting_resistivity_model)

Running inversion with SimPEG v0.22.1

                        simpeg.InvProblem is setting bfgsH0 to the inverse of the eval2Deriv.
                        ***Done using same Solver, and solver_opts as the Simulation2DNodal problem***
                        
model has any nan: 0
============================ Inexact Gauss Newton ============================
  #     beta     phi_d     phi_m       f      |proj(x-g)-x|  LS    Comment   
-----------------------------------------------------------------------------
x0 has any nan: 0
   0  1.88e+00  4.48e+04  0.00e+00  4.48e+04    5.38e+03      0              
   1  1.88e+00  2.32e+03  1.17e+03  4.52e+03    4.18e+02      0              
   2  9.42e-01  9.53e+02  1.60e+03  2.46e+03    1.35e+02      0   Skip BFGS  
   3  9.42e-01  4.87e+02  1.91e+03  2.29e+03    1.36e+01      0   Skip BFGS  
   4  4.71e-01  4.85e+02  1.91e+03  1.38e+03    8.88e+01      0              
   5  4.71e-01  2.61e+02  2.21e+03  1.30e+03    6.89e+00      0              
   6  2.35e-01  2.64e+02  2.20e+03  7.81e+02    4.88e+01      0              
   7  2.35e-01  1.65e+02  2.46e+03  7.45e+02    4.12e+00      0              
   8  1.18e-01  1.67e+02  2.45e+03  4.56e+02    2.65e+01      0              
Reached starting chifact with l2-norm regularization: Start IRLS steps...
irls_threshold 2.6867301855615753
   9  1.18e-01  1.21e+02  3.59e+03  5.43e+02    1.03e+01      0              
  10  1.18e-01  1.29e+02  3.87e+03  5.84e+02    6.19e+00      0              
  11  1.18e-01  1.31e+02  4.04e+03  6.08e+02    4.47e+00      0   Skip BFGS  
  12  1.18e-01  1.33e+02  4.16e+03  6.23e+02    4.62e+00      0   Skip BFGS  
  13  1.18e-01  1.34e+02  4.21e+03  6.30e+02    4.85e+00      0              
  14  1.18e-01  1.36e+02  4.19e+03  6.29e+02    5.01e+00      0              
  15  1.18e-01  1.36e+02  4.11e+03  6.20e+02    5.15e+00      0              
  16  1.18e-01  1.37e+02  3.97e+03  6.05e+02    5.32e+00      0              
  17  1.18e-01  1.37e+02  3.81e+03  5.85e+02    5.52e+00      0              
  18  1.18e-01  1.37e+02  3.63e+03  5.64e+02    5.80e+00      0              
  19  1.18e-01  1.37e+02  3.46e+03  5.44e+02    6.25e+00      0              
  20  1.18e-01  1.37e+02  3.28e+03  5.23e+02    6.43e+00      0              
  21  1.18e-01  1.37e+02  3.11e+03  5.03e+02    6.51e+00      0              
  22  1.18e-01  1.37e+02  2.94e+03  4.83e+02    6.59e+00      0              
  23  1.18e-01  1.37e+02  2.77e+03  4.63e+02    6.75e+00      0              
  24  1.18e-01  1.37e+02  2.61e+03  4.44e+02    7.06e+00      0              
  25  1.18e-01  1.36e+02  2.46e+03  4.25e+02    7.53e+00      0              
  26  1.18e-01  1.34e+02  2.31e+03  4.07e+02    7.91e+00      0              
  27  1.18e-01  1.33e+02  2.19e+03  3.91e+02    7.94e+00      0              
  28  1.18e-01  1.32e+02  2.08e+03  3.76e+02    7.78e+00      0   Skip BFGS  
  29  1.18e-01  1.32e+02  1.98e+03  3.64e+02    7.60e+00      0   Skip BFGS  
  30  1.18e-01  1.32e+02  1.89e+03  3.54e+02    7.48e+00      0   Skip BFGS  
  31  1.18e-01  1.32e+02  1.81e+03  3.45e+02    7.43e+00      0   Skip BFGS  
  32  1.18e-01  1.32e+02  1.74e+03  3.37e+02    7.42e+00      0   Skip BFGS  
  33  1.18e-01  1.33e+02  1.68e+03  3.30e+02    7.69e+00      0   Skip BFGS  
  34  1.18e-01  1.34e+02  1.63e+03  3.25e+02    7.85e+00      0   Skip BFGS  
  35  1.18e-01  1.34e+02  1.58e+03  3.21e+02    8.00e+00      0   Skip BFGS  
  36  1.18e-01  1.35e+02  1.54e+03  3.17e+02    8.18e+00      0   Skip BFGS  
  37  1.18e-01  1.36e+02  1.51e+03  3.14e+02    8.40e+00      0   Skip BFGS  
  38  1.18e-01  1.36e+02  1.48e+03  3.10e+02    8.59e+00      0   Skip BFGS  
Reach maximum number of IRLS cycles: 30
------------------------- STOP! -------------------------
1 : |fc-fOld| = 0.0000e+00 <= tolF*(1+|f0|) = 4.4788e+03
1 : |xc-x_last| = 2.6903e-01 <= tolX*(1+|x0|) = 3.8957e+01
0 : |proj(x-g)-x|    = 8.5948e+00 <= tolG          = 1.0000e-01
0 : |proj(x-g)-x|    = 8.5948e+00 <= 1e3*eps       = 1.0000e-02
0 : maxIter   =      50    <= iter          =     39
------------------------- DONE! -------------------------

Plot True, L2 and IRLS Models

Here, we compare the models recovered from weighted least-squares and iteratively re-weighted least-squares inversion to the true model.

# Recreate the true model
true_background_conductivity = 1e-2
true_conductor_conductivity = 1e-1
true_resistor_conductivity = 1e-3

true_conductivity_model = true_background_conductivity * np.ones(n_active)

ind_conductor = model_builder.get_indices_sphere(
    np.r_[-120.0, 40.0], 60.0, mesh.cell_centers[active_cells, :]
)
true_conductivity_model[ind_conductor] = true_conductor_conductivity

ind_resistor = model_builder.get_indices_sphere(
    np.r_[120.0, 72.0], 60.0, mesh.cell_centers[active_cells, :]
)
true_conductivity_model[ind_resistor] = true_resistor_conductivity
# Convert recovered log-resistivities to conductivities
recovered_conductivity_irls = 1 / np.exp(recovered_log_resistivity_model)
# Convert to subsurface conductivity values
plotting_model = [
    true_conductivity_model,
    recovered_conductivity_L2,
    recovered_conductivity_irls,
]

fig = plt.figure(figsize=(9, 13))
ax1 = 3 * [None]
ax2 = 3 * [None]
title_str = [
    "True Conductivity Model",
    "Recovered Model (L2)",
    "Recovered Model (IRLS)",
]

for ii in range(0, 3):
    ax1[ii] = fig.add_axes([0.14, 0.75 - 0.3 * ii, 0.68, 0.22])
    mesh.plot_image(
        plotting_map * plotting_model[ii],
        ax=ax1[ii],
        grid=False,
        pcolor_opts={"norm": norm, "cmap": mpl.cm.RdYlBu_r},
    )
    ax1[ii].set_xlim(-500, 500)
    ax1[ii].set_ylim(-300, 200)
    ax1[ii].set_title(title_str[ii])
    ax1[ii].set_xlabel("x (m)")
    ax1[ii].set_ylabel("z (m)")

    ax2[ii] = fig.add_axes([0.84, 0.75 - 0.3 * ii, 0.03, 0.22])
    cbar = mpl.colorbar.ColorbarBase(
        ax2[ii], norm=norm, orientation="vertical", cmap=mpl.cm.RdYlBu_r
    )
    cbar.set_label(r"$\sigma$ (S/m)", rotation=270, labelpad=15, size=12)

plt.show()
<Figure size 900x1300 with 6 Axes>