This program was contributed by Jonas Plank <[email protected]>.
It comes without any warranty or support by its authors or the authors of deal.II.
This program is part of the deal.II code gallery and consists of the following files (click to inspect):
Pictures from this code gallery program
Annotated version of README.md
Multilevel Monte Carlo for random Darcy flow
This code is an implementation of the Multilevel Monte Carlo (MLMC) approach described in the amazing paper by Cliffe et al. [3]. While many Darcy flow solvers in porous media research rely on the Finite Volume Method (FVM), this project specifically utilizes the Finite Element Method (FEM), leveraging the deal.II library for spatial discretization.
Motivation
Numerical simulations rely on input parameters such as material data, boundary conditions, and geometry. These parameters are almost always derived from measurements and are therefore subject to uncertainty. Some PDEs, such as the Kardar-Parisi-Zhang equation [1], even incorporate uncertainty directly into the governing equation. This uncertainty propagates through the simulation; consequently, quantifying its impact on the solution is of great interest.
However, this can be a costly undertaking. The standard Monte Carlo estimator, which simply solves the equation \(N\) times with different realizations of the random noise and then averages the \(N\) solutions, converges at a rate of
\[ \mathcal{O}(N^{-\frac{1}{2}}) \]
, potentially requiring tens of thousands of samples for a reasonable estimate of the mean. This is often infeasible for complex problems where computing a single sample on a fine mesh may take hours.
This is where the Multilevel Monte Carlo (MLMC) estimator is utilized. The method exploits a simple identity: to estimate the expectation of a problem on a fine mesh
\[ \mathcal{E}[Q_M]\]
, we can use the linearity of expectation to expand the estimator as a telescoping sum:
\[ \mathcal{E}[Q_M] = \mathcal{E}[Q_0] + \sum_{l=1}^{M} \mathcal{E}[Q_l - Q_{l-1}] \]
.
One run on level
\[ M-1 \]
is significantly cheaper than one run on level
\[ M \]
. Furthermore, the difference between
\[ Q_M \]
and
\[ Q_{M-1} \]
is likely to be small, as the solutions will be similar when using the same realizations of the random field. By running the discretized PDE on several mesh levels, we can allocate more samples to the inexpensive coarse levels and fewer samples to the expensive fine levels. This significantly reduces the total computational effort.
A prominent application for this method is Darcy's Law [2], which describes flow through porous media. In this context, randomness is introduced via the hydraulic conductivity tensor.
Problem statement
Let the infinite probability space be
\[(\Omega, \mathcal{F}, \mathbb{P})\]
and the domain be
\[D \subset \mathbb{R}^d, d=1,2,3 \]
. We model the hydraulic conductivity as a random field
\[ k = k(x, \omega)\]
on
\[D \times \Omega \]
with a defined mean and covariance. The governing equation is:
\[
-\nabla \cdot ( k(x,\omega)\nabla p(x, \omega)) = f(x).
\]
We assume the right-hand side
\[f(x) \]
is deterministic. Solving this general form is challenging; therefore, we assume
\[ k(x, \omega) \]
follows a log-normal distribution. This involves replacing the conductivity tensor with a scalar-valued field whose logarithm is Gaussian, which guarantees that
\[ k > 0 \]
almost surely [3]. Additionally, for an exponential kernel, analytical solutions for the integral eigenvalue problem are available.
We approximate this random field using a Karhunen-Loève Expansion and set a pressure difference of 1 between the left and right boundaries.
It is easy to observe that the actual PDE code is very similar to the one demonstrated in step-5. This is on purpose. In recent years, invasive techniques have fallen out of favor since they, as the name suggests, require reprogramming of existing finite element codes.
Results
The following plots illustrate the convergence rate of our MLMC implementation. 
In the left image, the variance significantly decreases with each additional level. This supports the observation that solutions become increasingly similar as the discretization is refined for the same samples. The central plot shows the change in the mean with each level. The fact that the mean continues to shift substantially at levels 2 and 3 suggests that the initial mesh may have been too coarse. This is further supported by the right plot, which shows a sharp drop in the total number of samples required to reach the target tolerance. While the number of samples required for levels 1 and 2 is similar—again suggesting an overly coarse initial discretization—there is a significant reduction in samples for the higher levels. We conclude that the implementation successfully achieves the expected MLMC convergence behavior.
These plots can be generated using the Python script located in utils/.
Finally, a single realization of the solution is shown below. 
References
[1] https://en.wikipedia.org/wiki/Kardar-Parisi-Zhang_equation [2] https://en.wikipedia.org/wiki/Darcy%27s_law [3] Cliffe, K.A., Giles, M.B., Scheichl, R. et al. Multilevel Monte Carlo methods and applications to elliptic PDEs with random coefficients. Comput. Visual Sci. 14, 3 (2011). https://doi.org/10.1007/s00791-011-0160-x
Annotated version of include/KL_expansion.h
#include <deal.II/base/point.h>
#include <deal.II/base/function.h>
KLExpansion(
const unsigned int n_terms,
double L,
double l,
double mu);
double compute_kl_expansion(
const Point<dim>& p, std::vector<double> &samples);
* * * struct InterferenceTaperTransform *
computes our coefficients
computes our eigenvalues based on the previously computed frequencies
computes norm values such that our eigenfunctions are normed to one.
compute the frequencies
functions we need for the computation of omega_i
double f_even(
double sol);
double f_odd(
double sol);
double grad_f_even(
double sol);
double grad_f_odd(
double sol);
newton since equation is nonlinear
void newton_even(
unsigned int index);
void newton_odd(
unsigned int index);
number of KL expansion terms
domain length
correlation length
double correlation_length_;
important parameters for the construction of the KL-Expansion in x-direction
std::vector<double> omega_i;
std::vector<double> lambda_i;
std::vector<double> alpha_i;
constant mean of our field.
Annotated version of include/mlmc.h
namespace MultilevelMonteCarlo
MLMC(
unsigned int oneD_samples);
generates the required number of samples
std::vector<double> generate_samples();
store the samples for the computation of mean and variance
void add_sample(
double rvalue);
double compute_variance();
removes all samples after reached convergence on a level
std::normal_distribution<double> dist;
unsigned int num_samples;
parameters for error computation
std::vector<double> results;
Annotated version of include/random_darcy.h
#include <deal.II/base/quadrature_lib.h>
#include <deal.II/dofs/dof_handler.h>
#include <deal.II/dofs/dof_tools.h>
#include <deal.II/fe/fe_values.h>
#include <deal.II/grid/tria.h>
#include <deal.II/grid/grid_generator.h>
#include <deal.II/lac/dynamic_sparsity_pattern.h>
#include <deal.II/lac/full_matrix.h>
#include <deal.II/lac/sparse_matrix.h>
#include <deal.II/lac/vector.h>
#include <deal.II/numerics/data_out.h>
#include <deal.II/numerics/vector_tools.h>
#include <deal.II/fe/fe_q.h>
#include <deal.II/grid/grid_out.h>
#include <deal.II/lac/affine_constraints.h>
#include <deal.II/grid/grid_refinement.h>
#include <deal.II/numerics/error_estimator.h>
#include <deal.II/base/function.h>
#include
"random_permeability.h"
Allows switching between coarse and fine triangulations with virtually no code duplication.
void set_tria(
bool fine);
void generate_mesh(
double domain_length);
Implementation follows the logic of deal.II tutorial step-5.
void assemble_system(RandomField::RandomPermeability<dim>& random_constant);
If we are on the first level, we only want to refine the fine triangulation.
void refine_grid(
bool firstRun);
On the first run, we label the output as "coarse" since both meshes are identical. After the first level, the meshes diverge, and we output the finer mesh.
void output_results(
bool firstRun,
unsigned int level);
This is our Quantity of Interest (QoI). It is defined as: \(K_{eff} = - \int_{\Gamma_{right}} k \frac{\partial p}{\partial x_1} dx_2\)
double compute_Keff(RandomField::RandomPermeability<dim>& permeability);
define two triangulations
Annotated version of include/random_permeability.h
#include
"KL_expansion.h"
class RandomPermeability :
public Function<dim>
RandomPermeability(std::vector<double> &first_sample,
unsigned int n_terms,
double domain_length,
double correlation_length,
double mu);
overwrite the samples
void overwrite_samples(
const std::vector<double> &next_sample);
our evaluator
KLExpansion<dim> kl_expansion;
std::vector<double> samples;
Annotated version of main.cc
#include
"include/random_permeability.h"
#include
"include/random_darcy.h"
#include
"include/mlmc.h"
std::ofstream outfile(
"mlmc_results.txt");
if (!outfile.is_open()) {
std::cerr <<
"Error: Could not open mlmc_results.txt for writing!" << std::endl;
* * int main(int argc, char **argv)
Run parameters
double domain_length = 10.0;
double correlation_length = 5.0;
The number of terms chosen for 1D. For higher dimensions, we use oneD_samples^dim.
unsigned int oneD_samples = 20;
The number of levels was not chosen arbitrarily. Convergence is usually so fast that using more than 4–5 levels is rarely necessary.
We used a constant mean here. While this was a specific choice for this case, in practice, most mean functions are spatially varying rather than constant.
In general, these values should be determined by a pilot run. I chose these specific values to keep the implementation simple.
std::vector<unsigned int> runs_per_level{20000, 10000, 5000, 2500, 1000, 250};
MultilevelMonteCarlo::MLMC<2> mlmc(oneD_samples);
std::vector<double> first_sample{};
RandomField::RandomPermeability<2> permeability(first_sample, oneD_samples, domain_length, correlation_length, mu);
Discretization::RandomDarcy<2> random_darcy;
random_darcy.generate_mesh(domain_length);
double global_mean = 0.0;
for(
unsigned int i = 0; i<=levels; i++)
std::cout <<
"Level:" << i << std::endl;
for(
unsigned int j = 0; j<runs_per_level[i]; j++)
permeability.overwrite_samples(mlmc.generate_samples());
coarse run
random_darcy.set_tria(
false);
random_darcy.setup_system();
random_darcy.assemble_system(permeability);
double Keff_coarse = random_darcy.compute_Keff(permeability);
mlmc.add_sample(Keff_coarse);
fine run
random_darcy.set_tria(
true);
random_darcy.setup_system();
random_darcy.assemble_system(permeability);
double Keff_fine = random_darcy.compute_Keff(permeability);
mlmc.add_sample(Keff_fine-Keff_coarse);
double var = mlmc.compute_variance();
if (var / (j+1) < tolerance * tolerance)
global_mean+= mlmc.compute_mean();
std::cout <<
"number of samples for level" << i <<
":" << j << std::endl;
random_darcy.output_results(i==0, i);
outfile <<
"FINAL_STATS Level:" << i <<
" Mean:" << mlmc.compute_mean()
<<
" Var:" << mlmc.compute_variance() <<
" Samples:" << j << std::endl;
random_darcy.refine_grid(i==0);
std::cout <<
"global mean" << global_mean << std::endl;
Annotated version of source/KL_expansion.cc
#include
"../include/KL_expansion.h"
RandomField::KLExpansion<dim>::KLExpansion(
const unsigned int n_terms,
double domain_length,
double correlation_length,
double mu)
: n_terms_(n_terms)
, domain_length_(domain_length)
, correlation_length_(correlation_length)
double RandomField::KLExpansion<dim>::compute_kl_expansion(
const Point<dim>& p, std::vector<double> &samples)
{
for(
unsigned int i = 0; i<n_terms_; i++)
even
val +=
std::sqrt(lambda_i[i])*samples[i]*alpha_i[i]*
std::sin(omega_i[i]*(p[0]-domain_length_/2));
::VectorizedArray< Number, width > sin(const ::VectorizedArray< Number, width > &)
::VectorizedArray< Number, width > sqrt(const ::VectorizedArray< Number, width > &)
odd
val +=
std::sqrt(lambda_i[i])*samples[i]*alpha_i[i]*
std::cos(omega_i[i]*(p[0]-domain_length_/2));
for(
unsigned int i = 0; i<n_terms_; i++)
for(
unsigned int j = 0; j<n_terms_; j++)
::VectorizedArray< Number, width > cos(const ::VectorizedArray< Number, width > &)
both even
if((i+1)%2==0 && (j+1)%2==0)
val+=
std::sqrt(lambda_i[i]*lambda_i[j])*samples[i*n_terms_+j]*alpha_i[i]*alpha_i[j]*
std::sin(omega_i[i]*(p[0]-domain_length_/2))*
std::sin(omega_i[j]*(p[1]-domain_length_/2));
both odd
else if((i+1)%2==1 && (j+1)%2==1)
val+=
std::sqrt(lambda_i[i]*lambda_i[j])*samples[i*n_terms_+j]*alpha_i[i]*alpha_i[j]*
std::cos(omega_i[i]*(p[0]-domain_length_/2))*
std::cos(omega_i[j]*(p[1]-domain_length_/2));
i odd
else if((i+1)%2==1 && (j+1)%2==0)
val+=
std::sqrt(lambda_i[i]*lambda_i[j])*samples[i*n_terms_+j]*alpha_i[i]*alpha_i[j]*
std::cos(omega_i[i]*(p[0]-domain_length_/2))*
std::sin(omega_i[j]*(p[1]-domain_length_/2));
j odd
else if((i+1)%2==0 && (j+1)%2==1)
val+=
std::sqrt(lambda_i[i]*lambda_i[j])*samples[i*n_terms_+j]*alpha_i[i]*alpha_i[j]*
std::sin(omega_i[i]*(p[0]-domain_length_/2))*
std::cos(omega_i[j]*(p[1]-domain_length_/2));
void RandomField::KLExpansion<dim>::compute_omega_i()
for(
unsigned int i = 1; i<=n_terms_; i++)
void RandomField::KLExpansion<dim>::compute_alpha_i()
for(
unsigned int i = 1; i<=n_terms_; i++)
double w_i = omega_i[i-1];
double sqrt_val = domain_length_/2 -
std::sin(w_i*domain_length_)/(2*w_i);
double w_i = omega_i[i-1];
double sqrt_val = domain_length_/2 +
std::sin(w_i*domain_length_)/(2*w_i);
void RandomField::KLExpansion<dim>::compute_lambda_i()
for(
unsigned int i = 1; i<=n_terms_; i++)
double w_i = omega_i[i-1];
double lambda = 2*correlation_length_/(1+w_i*w_i*correlation_length_*correlation_length_);
lambda_i.push_back(lambda);
void RandomField::KLExpansion<dim>::newton_even(
unsigned int index)
{
double a = M_PI / domain_length_ * (
index - 1);
double b = M_PI / domain_length_ *
index;
double x = a + 0.1 * (
b - a);
for (
int i = 0; i < 50; ++i)
double dfx = grad_f_even(x);
while ((x_new <= a || x_new >= b) && backtrack_count < 10)
if (x_new <= a || x_new >= b)
break;
if (!std::isfinite(x_new))
break;
void RandomField::KLExpansion<dim>::newton_odd(
unsigned int index)
{
double x = M_PI / domain_length_ * (
static_cast<double>(
index) - 0.2);
for (
int i = 0; i < 50; ++i)
double dfx = grad_f_odd(x);
double x_new = x - fx / dfx;
if (!std::isfinite(x_new))
break;
double RandomField::KLExpansion<dim>::f_odd(
double x)
{
return 1.0 / correlation_length_ - x *
std::tan(x * domain_length_ / 2.0);
double RandomField::KLExpansion<dim>::f_even(
double x)
{
return (1.0 / correlation_length_) *
std::tan(x * domain_length_ / 2.0) + x;
double RandomField::KLExpansion<dim>::grad_f_odd(
double x)
{
const double L = domain_length_;
const double sec2 = 1.0 /
std::cos(x * L / 2.0);
return -t - x * (
L / 2.0) * sec2 * sec2;
double RandomField::KLExpansion<dim>::grad_f_even(
double x)
{
const double L = domain_length_;
const double sec2 = 1.0 /
std::cos(x * L / 2.0);
return (1.0 / correlation_length_) * (
L / 2.0) * sec2 * sec2 + 1.0;
template class RandomField::KLExpansion<1>;
template class RandomField::KLExpansion<2>;
SymmetricTensor< 2, dim, Number > b(const Tensor< 2, dim, Number > &F)
::VectorizedArray< Number, width > tan(const ::VectorizedArray< Number, width > &)
::VectorizedArray< Number, width > abs(const ::VectorizedArray< Number, width > &)
Annotated version of source/mlmc.cc
#include
"../include/mlmc.h"
MultilevelMonteCarlo::MLMC<dim>::MLMC(
unsigned int oneD_samples)
: rng(
std::random_device{}())
if constexpr (dim == 1) num_samples = oneD_samples;
else if constexpr (dim == 2) num_samples = oneD_samples*oneD_samples;
std::vector<double> MultilevelMonteCarlo::MLMC<dim>::generate_samples()
std::vector<double> samples(num_samples);
void MultilevelMonteCarlo::MLMC<dim>::add_sample(
double rvalue)
{
results.push_back(rvalue);
double MultilevelMonteCarlo::MLMC<dim>::compute_mean()
for(
unsigned int i = 0; i<results.size(); i++)
return mean/results.size();
double MultilevelMonteCarlo::MLMC<dim>::compute_variance()
double mean = compute_mean();
for(
unsigned int i = 0; i<results.size(); i++)
return var / (results.size() - 1);
void MultilevelMonteCarlo::MLMC<dim>::clear_samples()
template class MultilevelMonteCarlo::MLMC<1>;
template class MultilevelMonteCarlo::MLMC<2>;
::VectorizedArray< Number, width > pow(const ::VectorizedArray< Number, width > &, const Number p)
Annotated version of source/random_darcy.cc
#include
"../include/random_darcy.h"
#include <deal.II/lac/sparse_direct.h>
Discretization::RandomDarcy<dim>::RandomDarcy()
, dof_handler(coarse_tria)
void Discretization::RandomDarcy<dim>::set_tria(
bool fine)
{
dof_handler.reinit(fine_tria);
dof_handler.reinit(coarse_tria);
void Discretization::RandomDarcy<dim>::generate_mesh(
double domain_length)
{
coarse_tria.refine_global(3);
fine_tria.refine_global(3);
void Discretization::RandomDarcy<dim>::setup_system()
dof_handler.distribute_dofs(fe);
solution.reinit(dof_handler.n_dofs());
system_rhs.reinit(dof_handler.n_dofs());
void make_hanging_node_constraints(const DoFHandler< dim, spacedim > &dof_handler, AffineConstraints< number > &constraints)
void hyper_cube(Triangulation< dim, spacedim > &tria, const double left=0., const double right=1., const bool colorize=false)
- Left boundary (indicator 0): u = 1.0
- Right boundary (indicator 1): u = 0.0
sparsity_pattern.copy_from(dsp);
system_matrix.reinit(sparsity_pattern);
void Discretization::RandomDarcy<dim>::assemble_system(RandomField::RandomPermeability<dim>& permeability)
{
const unsigned int dofs_per_cell = fe.n_dofs_per_cell();
std::vector<types::global_dof_index> local_dof_indices(dofs_per_cell);
for (
const auto &cell : dof_handler.active_cell_iterators())
for (
const unsigned int q_index : fe_values.quadrature_point_indices())
const double current_coefficient =
permeability.value(fe_values.quadrature_point(q_index));
for (
const unsigned int i : fe_values.dof_indices())
for (const unsigned
int j : fe_values.dof_indices())
fe_values.shape_grad(i, q_index) *
fe_values.shape_grad(j, q_index) *
cell_rhs(i) += (fe_values.shape_value(i, q_index) *
cell->get_dof_indices(local_dof_indices);
constraints.distribute_local_to_global(
cell_matrix, cell_rhs, local_dof_indices, system_matrix, system_rhs);
void Discretization::RandomDarcy<dim>::solve()
A_direct.
solve(system_matrix, solution);
constraints.distribute(solution);
void Discretization::RandomDarcy<dim>::refine_grid(
bool firstRun)
{
fine_tria.refine_global(1);
coarse_tria.refine_global(1);
void Discretization::RandomDarcy<dim>::output_results(
bool firstRun,
unsigned int level)
{
data_out.add_data_vector(solution,
"solution");
data_out.build_patches();
std::ofstream output(firstRun ?
"solutionCoarse" + std::to_string(
level) +
".vtk" :
"solutionFine" +
std::
to_string(
level) +
".vtk");
data_out.write_vtk(output);
double Discretization::RandomDarcy<dim>::compute_Keff(RandomField::RandomPermeability<dim>& permeability)
{
const QGauss<dim - 1> face_quadrature_formula(fe.degree + 1);
std::vector<Tensor<1, dim>> solution_gradients(face_quadrature_formula.size());
for (
const auto &cell : dof_handler.active_cell_iterators())
{
for (
const auto face_no : cell->face_indices())
if (cell->face(face_no)->at_boundary() &&
fe_face_values.
reinit(cell, face_no);
fe_face_values.get_function_gradients(solution, solution_gradients);
for (
const unsigned int q_index : fe_face_values.quadrature_point_indices())
const double current_coefficient = permeability.
value(fe_face_values.quadrature_point(q_index));
Keff -= current_coefficient*solution_gradients[q_index][0]*fe_face_values.JxW(q_index);
template class Discretization::RandomDarcy<1>;
template class Discretization::RandomDarcy<2>;
* * for(const auto &cell :triangulation.active_cell_iterators())
void attach_dof_handler(const DoFHandler< dim, spacedim > &)
void solve(Vector< double > &rhs_and_solution, const bool transpose=false) const
void make_sparsity_pattern(const DoFHandler< dim, spacedim > &dof_handler, SparsityPatternBase &sparsity_pattern, const AffineConstraints< number > &constraints={}, const bool keep_constrained_dofs=true, const types::subdomain_id subdomain_id=numbers::invalid_subdomain_id)
@ update_values
Shape function values.
@ update_normal_vectors
Normal vectors.
@ update_JxW_values
Transformed quadrature weights.
@ update_gradients
Shape function gradients.
@ update_quadrature_points
Transformed quadrature points.
* * if(update_pressure &update_flags) * compute_pressure(constitutive_request
* * * * std::vector< Number > ThermoPlasticMaterial< dim, ViscoplasticYieldLaw, Number >::get_state_parameters const
void reinit(MatrixBlock< MatrixType > &v, const BlockSparsityPattern &p)
Annotated version of source/random_permeability.cc
#include
"../include/random_permeability.h"
RandomField::RandomPermeability<dim>::RandomPermeability(std::vector<double> &xi,
unsigned int n_terms,
double domain_length,
double correlation_length,
double mu)
: kl_expansion(n_terms, domain_length, correlation_length, mu)
void RandomField::RandomPermeability<dim>::overwrite_samples(const
std::vector<double> &next_sample)
double RandomField::RandomPermeability<dim>::value(
const Point<dim>& p)
{
return std::exp(kl_expansion.compute_kl_expansion(p, samples));
template class RandomField::RandomPermeability<1>;
template class RandomField::RandomPermeability<2>;
::VectorizedArray< Number, width > exp(const ::VectorizedArray< Number, width > &)
Annotated version of utils/post_processing.py
import re
import matplotlib.pyplot as plt
import numpy as np
import os
def plot_from_file(filename):
if not os.path.exists(filename):
print(f"Error: {filename} not found. Did you run the C++ code first or is it perhaps in the wrong folder?")
return
levels, means, variances, samples = [], [], [], []
pattern = re.compile(r"FINAL_STATS Level:(\d+) Mean:([\d\.e+-]+) Var:([\d\.e+-]+) Samples:(\d+)")
with open(filename, 'r') as f:
for line in f:
match = pattern.search(line)
if match:
levels.append(
int(match.group(1)))
means.append(abs(float(match.group(2))))
variances.append(float(match.group(3)))
samples.append(
int(match.group(4)))
if not levels:
print("No valid MLMC data found in the file.")
return
levels = np.array(levels)
fig, axs = plt.subplots(1, 3, figsize=(16, 5))
axs[0].plot(levels, variances, 'o-', color='firebrick', label=r'Var[@f$P_l - P_{l-1}@f$]')
axs[0].set_yscale('log')
axs[0].set_title('Variance Decay', fontsize=12, fontweight='bold')
axs[0].set_xlabel('Level')
axs[0].grid(True, which='both', alpha=0.3)
axs[0].legend()
axs[1].plot(levels, means, 's-', color='royalblue', label=r'|@f$E[P_l - P_{l-1}]@f$|')
axs[1].set_yscale('log')
axs[1].set_title('Mean Difference (Bias)', fontsize=12, fontweight='bold')
axs[1].set_xlabel('Level')
axs[1].grid(True, which='both', alpha=0.3)
axs[1].legend()
axs[2].bar(levels, samples, color='seagreen', alpha=0.7)
axs[2].set_yscale('log')
axs[2].set_title('Samples per Level (Workload)', fontsize=12, fontweight='bold')
axs[2].set_xlabel('Level')
axs[2].set_ylabel('@f$N_l@f$')
axs[2].grid(axis='y', alpha=0.3, linestyle='--')
plt.tight_layout()
plt.savefig('mlmc_plots.png', dpi=150)
print("Plot saved as 'mlmc_plots.png'")
plt.show()
if __name__ == "__main__":
plot_from_file('mlmc_results.txt')