Skip to content

Add gmrf and precision builder functions as precursors for eventually adding DSEM - #1594

Open
e-perl-NOAA wants to merge 32 commits into
NOAA-FIMS:mainfrom
e-perl-NOAA:dsem
Open

Add gmrf and precision builder functions as precursors for eventually adding DSEM#1594
e-perl-NOAA wants to merge 32 commits into
NOAA-FIMS:mainfrom
e-perl-NOAA:dsem

Conversation

@e-perl-NOAA

@e-perl-NOAA e-perl-NOAA commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Disclaimer
I don't actually know what I'm doing here, but there was enthusiasm for progress being made on getting DSEM into FIMS so I embarked on this journey ⛵ with nothing but Copilot and a dream ☁️. Please keep that in mind as you make comments and I beg you to explain things in a way that you would see them in those "XXX for Dummies" books.

What is the feature?

Getting the behind the scenes functions of gmrf and a precision builder (that can be used with more than just dsem) that are precursors seemed like a good point to start merging things in since myself and copilot/notebookLM then all started to get confused on how to proceed from here (it started going a bit off the rails once we started getting to what needed to be added in the information.hpp file) . I don't even think that these functions are "exposed" enough to write tests for? Maybe I'm wrong here and if so, @Bai-Li-NOAA I would DEFINITELY need your help.

Once it seems like there is consensus that these are a good way forward and/or good to go, they can be merged in and I/we can incrementally work on getting DSEM into FIMS in subsequent PRs. Hopefully that method confuses copilot/notebookLM (and honestly, myself) less.

How have you implemented the solution?

  • Add gmrf function
  • Add precision builders function - this can eventually take more than just DSEM

Instructions for code reviewer

👋Hello reviewer👋, thank you for taking the time to review this PR!

  • Please use this checklist during your review, checking off items that you have verified are complete but feel free to skip over items that are not relevant!
  • See the GitHub documentation for how to comment on a PR to indicate where you have questions or changes are needed before approving the PR.
  • Please use standard conventional messages for both commit messages and comments
  • PR reviews are a great way to learn so feel free to share your tips and tricks. However, when suggesting changes to the PR that are optional please include nit: (for nitpicking) as the comment type. For example, nit: I prefer using a data.frame() instead of a matrix because ...
  • Engage with the developer. Make it clear when the PR is approved by selecting the approved status, and potentially commenting on the PR with something like This PR is now ready to be merged.

Checklist

  • The code is well-designed
  • The code is designed well for both users and developers
  • Code coverage remains high- [ ] Comments are clear, useful, and explain why instead of what
  • Code is appropriately documented (doxygen and roxygen)

@github-actions

Copy link
Copy Markdown
Contributor

🎨 Chore: code formatting workflow

Our automated workflows cannot run on forks because of permission issues, and thus, we ask that you run the following code locally and push any changes that are created to your feature branch. You will only be reminded of this once per PR. Thank you!

Format C++ code

  1. Install clang-format version 18.0.0
  2. Run the following command from the repository root:
    clang-format -i --style="{BasedOnStyle: Google, SortIncludes: false}" $(find ./inst/include ./src ./tests/gtest -name "*.hpp" -o -name "*.cpp")

Format R code

  1. Install {styler} and {roxygen2}
  2. Run the following commands in R from the repository root:
styler::style_pkg() # Style R code
roxygen2::roxygenise() # Update documentation
styler::style_pkg() # Style R code again
roxygen2::roxygenise() # Update documentation again
usethis::use_tidy_description() # Style DESCRIPTION file

Push changes

  1. Commit the formatting with a commit message of "Chore: format feature branch"
  2. Push to your fork

@Bai-Li-NOAA

Copy link
Copy Markdown
Collaborator

I don't even think that these functions are "exposed" enough to write tests for? Maybe I'm wrong here and if so, @Bai-Li-NOAA I would DEFINITELY need your help.

Wow 😮 , you did it! I haven't taken a close look at the code in those files yet, but for gmrf.hpp and precision_builder.hpp, you could write C++ unit tests using FIMS:::use_gtest_template(). If you'd like, here are some additional instructions for Fernando.

For the rcpp_*.hpp files, yes, they'll need to be added to inst/include/interface/rcpp/rcpp_interface.hpp and exposed to R. After that, we can write testthat tests for those modules. You can find an example here.

I'm not sure what your timeline is for working on the tests, but I'll be setting aside dedicated time each week to work on testing-related issues. Feel free to join those sessions (starting July 9 and listed on the FIMS calendar) if you have any questions or would like to work through any testing challenges together!

@e-perl-NOAA

Copy link
Copy Markdown
Contributor Author

@Bai-Li-NOAA Yeah, that sounds great! I won't have time to work on tests this week, but I'll definitely have questions for you when I start working on it next week! Also, I love that you are leaning into me naming my AI/agents Fernando 🤣

@kellijohnson-NOAA

Copy link
Copy Markdown
Contributor

@nathanvaughan-NOAA can you review this PR?

@e-perl-NOAA

Copy link
Copy Markdown
Contributor Author

@kellijohnson-NOAA I need to add tests, I just haven't had time to work on them 😞

@nathanvaughan-NOAA

Copy link
Copy Markdown
Contributor

No worries @e-perl-NOAA, Fernando is a busy guy I'm sure he'll get to them eventually 😁 I'll just go through the functions.

Comment thread inst/include/distributions/functors/gmrf.hpp Outdated
@Andrea-Havron-NOAA

Copy link
Copy Markdown
Collaborator

@e-perl-NOAA, there are two issues:

  1. beta_z is a parameter vector that is used to both assemble and scale the precision matrix. We can simplify code by keeping beta_z a member of the dsem module ONLY and use the value to scale the precision matrix in the GMRF call.
  2. There isn't any code linking the DSEMPrecisinMatrixBuilder to Q in the GMRF.

I started working on a comment that included a bunch of code - I realized it might be easier to set up a co-working session to go over these changes together. I think Fernando has gotten you to a nice spot, but at this point, writing more instructions to AI would probably take more time than implementing the needed changes directly!

}

// Register builder in Information Map (to be accessed by GMRF distribution)
info->dsem_builders[this->id] = builder;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

dsem_builders does not exist in information. More code is needed to link the precision matrix builder to the GMRF both here and in information.

};

/**
* @brief Rcpp interface for DSEM growth.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

remove beta_z from the next four files (rcpp_growth, rcpp_maturity, rcpp_recruitment, and rcpp_selectivity)

Comment thread inst/include/interface/rcpp/rcpp_objects/wip.cpp Outdated
}

// Centering: x - mu because TMB's GMRF expects input centered around a mean of 0.
vector<Type> x_centered(n_x);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this needs to be std::vector

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would also be faster to have

vector x_centered;
x_centered.reserve(n_x);
for (size_t i = 0; i < n_x; ++i) {
x_centered.emplace_back(this->get_observed(i) - this->get_expected(i));
}

this avoids filling all the x_centered values with default first and overwriting them, emplace also avoids creating and then moving the centered value which push_back does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@nathanvaughan-NOAA
This is what implementing your suggestion gave me, does this look right?

        // Centering: x - mu. TMB's GMRF expects input centered around a mean of 0.
        // To improve performance, we build a std::vector using emplace_back to avoid
        // default-constructing and then reassigning every element.
        std::vector<Type> x_centered_std;
        x_centered_std.reserve(n_x);
        for (size_t i = 0; i < n_x; ++i) {
            x_centered_std.emplace_back(this->get_observed(i) - this->get_expected(i));
        }

        // Evaluate TMB GMRF and multiply by -1 to convert from negative log-likelihood to log-likelihood.
        this->lpdf = -1.0 * density::GMRF(*(this->precision_matrix_ptr))(Eigen::Map<const vector<Type>>(x_centered_std.data(), n_x));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good.


/**
* @brief Rcpp interface for DSEM growth.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should avoid setting this up to create module specific DSEM classes. Everything should be handled in the dsem interface so it can then point to arbitrary variables in any current/future modules without adding development overhead to them.

int type = 0; /**< 1 = A path effect (Rho), 2 = Variance (Gamma) */
int from = 0; /**< The variable the arrow starts from */
int to = 0; /**< The variable the arrow points to */
int beta_index = 0; /**< Which parameter in beta_z to use for this arrow */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Am I right that using the index here is basically so you can map off multiple linkages to be estimated together? I'm not sure how common that would be but it may make more sense just to have the value in RAMPath and allow it to be estimable and/or mapped through TMB like other parameters? That basically replaces beta_index and start with beta_z that can be a fixed or estimated variable.

}

// Poke the "strength" into the correct grid slot based on arrow type.
if (this->paths[r].type == 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When I looked into SparseMatrix it looks like using .coeffRef is supposedly really slow due to memory moving issues and using Eigen::Triplet is much faster. Something like this apparently

int rows = 3;
int cols = 3;
Eigen::SparseMatrix<double> A(rows, cols);

std::vector<Eigen::Triplet<double>> tripletList;
tripletList.reserve(4); 

tripletList.push_back(Eigen::Triplet<double>(0, 0, 1.5));
tripletList.push_back(Eigen::Triplet<double>(1, 2, 2.0));
tripletList.push_back(Eigen::Triplet<double>(2, 1, -3.5));
tripletList.push_back(Eigen::Triplet<double>(2, 2, 4.0));

A.setFromTriplets(tripletList.begin(), tripletList.end());

A.makeCompressed(); 

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@nathanvaughan-NOAA When Fernando and I try to implement your suggestion it is giving me the following. Does this look right to you?

        // 2. Initialize sparse components
        // We create three empty grids that are "sparse"
        Eigen::SparseMatrix<Type> Rho_kk(static_cast<int>(n_k), static_cast<int>(n_k)); // Grid for causal paths (from -> to)
        Eigen::SparseMatrix<Type> Gamma_kk(static_cast<int>(n_k), static_cast<int>(n_k)); // Grid for variances (<->)
        Eigen::SparseMatrix<Type> I_kk(static_cast<int>(n_k), static_cast<int>(n_k));     // "Standard" grid (identity)
        I_kk.setIdentity(); // Fill diagonal with 1s

       // 3. Translate the "arrows" (RAMPath) into triplet lists for efficient sparse matrix construction.
        std::vector<Eigen::Triplet<Type>> rho_triplets;
        std::vector<Eigen::Triplet<Type>> gamma_triplets;
        rho_triplets.reserve(this->paths.size());
        gamma_triplets.reserve(this->paths.size());
        for (size_t r = 0; r < this->paths.size(); ++r) {
            // C++ starts counting at 0, but R starts at 1, so we subtract 1.
            const int from = this->paths[r].from - 1; 
            const int to = this->paths[r].to - 1;

            // Check if the user's arrow points to a slot that doesn't exist.
            if (from < 0 || to < 0 || static_cast<size_t>(from) >= n_k || 
                static_cast<size_t>(to) >= n_k) {
                throw std::invalid_argument(
                    "DSEMPrecisionMatrixBuilder: RAM indices out of bounds.");
            }

            // Determine the "strength" of this arrow. If beta_index is 1 or 
            // more, it's a parameter the model is guessing, otherwise, it's a fixed number.
            Type value = this->paths[r].start;
            if (this->paths[r].beta_index >= 1) {
                const size_t b_idx = static_cast<size_t>(this->paths[r].beta_index - 1);
                if (b_idx >= this->beta_z.size()) {
                    throw std::invalid_argument(
                        "DSEMPrecisionMatrixBuilder: beta_index points past beta_z size.");
                    }
                value = this->beta_z[b_idx];
            }

            // Add the "strength" to the correct triplet list based on arrow type.
            if (this->paths[r].type == 1) {
                rho_triplets.push_back(Eigen::Triplet<Type>(from, to, value));
            } else if (this->paths[r].type == 2) {
                gamma_triplets.push_back(Eigen::Triplet<Type>(from, to, value));
            }
        }

        Rho_kk.setFromTriplets(rho_triplets.begin(), rho_triplets.end());
        Gamma_kk.setFromTriplets(gamma_triplets.begin(), gamma_triplets.end());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yep this looks good to me, could probably use emplace_back here too to avoid the move but that is also a wider refactor question throughout FIMS.

}

// Centering: x - mu because TMB's GMRF expects input centered around a mean of 0.
vector<Type> x_centered(n_x);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would also be faster to have

vector x_centered;
x_centered.reserve(n_x);
for (size_t i = 0; i < n_x; ++i) {
x_centered.emplace_back(this->get_observed(i) - this->get_expected(i));
}

this avoids filling all the x_centered values with default first and overwriting them, emplace also avoids creating and then moving the centered value which push_back does.

e-perl-NOAA and others added 7 commits July 20, 2026 15:05
Comment thread inst/include/distributions/functors/precision_builders.hpp Outdated
@e-perl-NOAA

Copy link
Copy Markdown
Contributor Author

@Andrea-Havron-NOAA Also, gemini is convinced that the following lines that we added yesterday in the rcpp_precision_builders.hpp need to be removed:

std::shared_ptr<fims_distributions::GMRF<Type>> gmrf = 
            std::make_shared<fims_distributions::GMRF<Type>>();    

        gmrf->precision_matrix_ptr = builder; 

because of the following:
Incorrect Logic: The DSEMInterface's responsibility is to create and configure the DSEMPrecisionMatrixBuilder, not the GMRF distribution itself. The gmrf object created here is a local variable that is immediately destroyed, serving no purpose. The builder is correctly registered in the Information map to be used by a GMRF component later.
Type Mismatch: The assignment gmrf->precision_matrix_ptr = builder; would cause a compilation error. gmrf->precision_matrix_ptr is a std::shared_ptr<Eigen::SparseMatrix> (the matrix), but builder is a std::shared_ptr<fims_distributions::DSEMPrecisionMatrixBuilder> (the object that builds the matrix).

Does this seem right?

@kellijohnson-NOAA

Copy link
Copy Markdown
Contributor

@e-perl-NOAA should we mark this as a draft PR until you are ready for a final review? Or, is there something you currently need help with?

@e-perl-NOAA

Copy link
Copy Markdown
Contributor Author

@kellijohnson-NOAA I'm still waiting on some feedback from @Andrea-Havron-NOAA and/or @nathanvaughan-NOAA after my last two posts in the thread that contained some questions.

@nathanvaughan-NOAA

nathanvaughan-NOAA commented Jul 28, 2026 via email

Copy link
Copy Markdown
Contributor

@Andrea-Havron-NOAA

Copy link
Copy Markdown
Collaborator

@Andrea-Havron-NOAA Also, gemini is convinced that the following lines that we added yesterday in the rcpp_precision_builders.hpp need to be removed:

std::shared_ptr<fims_distributions::GMRF<Type>> gmrf = 
            std::make_shared<fims_distributions::GMRF<Type>>();    

        gmrf->precision_matrix_ptr = builder; 

because of the following: Incorrect Logic: The DSEMInterface's responsibility is to create and configure the DSEMPrecisionMatrixBuilder, not the GMRF distribution itself. The gmrf object created here is a local variable that is immediately destroyed, serving no purpose. The builder is correctly registered in the Information map to be used by a GMRF component later. Type Mismatch: The assignment gmrf->precision_matrix_ptr = builder; would cause a compilation error. gmrf->precision_matrix_ptr is a std::shared_ptrEigen::SparseMatrix (the matrix), but builder is a std::shared_ptr<fims_distributions::DSEMPrecisionMatrixBuilder> (the object that builds the matrix).

Does this seem right?

This is a bit philosophical. The GMRF is a distribution that requires a precision matrix. Rather than initialize the GMRF and its precision matrix independently and add a lot of checks to make sure the user set up both correctly and linked them together correctly, I think they should be linked internally. I decided that the user specifies the precision matrix (in this case, dsem), and by default, this initializes GMRF. We could rename the interface to dsemGMRFInterface to be more explicit about what is happening under the hood.

Given this, there are a couple of pieces missing as Gemini is right in that the gmrf is created locally and then destroyed. We can make the gmrf object persist by adding this code just below gmrf->precision_matrix_ptr = builder;:

gmrf->distribution_type = "random_effects";
info->density_components[gmrf->id] = gmrf;

Also precision_builder_ptr Type in gmrf.hpp should be updated to:
std::shared_ptr<fims_distributions::PrecisionMatrixBuilderBase<Type>> precision_builder_ptr = nullptr;

And then in the gmrf.hpp file, add:
Eigen::SparseMatrix<Type> Q = precision_builder_ptr->BuildPrecisionMatrixSparse();
and replace *(this->precision_matrix_ptr) in the GMRF with Q: this->lpdf = -1.0 * density::GMRF(Q)(Eigen::Map<const vector<Type>>(x_centered_std.data(), n_x));

There is still a missing piece, we will need to link the distribution's observed_values with the correct parameters. We will probably need to build this into the interface, I need to see what the R output from running the R dsem model looks like so I can think about this a bit more. I think this update can be worked on in the next PR though.

I did want to mention, another design direction would be to create a gmrf interface which creates the DSEM builder instead given a precision type input from the user. I guess my rational for thinking about it the other way is that the parameters are associated with the precision matrix, so it is easier to set them up for registration if the primary class being initialized is the precision matrix which then calls the GMRF rather than the other way around.

I will look over the suggested changes to precision_builders.hpp tomorrow!

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.73%. Comparing base (b7783d7) to head (15ea857).
⚠️ Report is 10 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1594      +/-   ##
==========================================
- Coverage   84.88%   83.73%   -1.15%     
==========================================
  Files         105       56      -49     
  Lines        9473     2232    -7241     
  Branches      536      539       +3     
==========================================
- Hits         8041     1869    -6172     
+ Misses       1395      298    -1097     
- Partials       37       65      +28     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

std::shared_ptr<fims_distributions::PrecisionMatrixBuilderBase<Type>> precision_matrix_ptr = nullptr;

// IS THIS WHERE THIS GOES?
Eigen::SparseMatrix<Type> Q = precision_matrix_ptr->BuildPrecisionMatrixSparse();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This needs to be inside the GMRF evaluate function

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants