Use cmdstan config and decouple stanfit objects from RunSet - #851
Conversation
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](actions/cache@v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
WardBrian
left a comment
There was a problem hiding this comment.
A few comments/questions, but overall I like the look of the code!
Responding to your comments in the PR:
- Runset being left out of the result entirely makes sense. We will have to see how this feels when we tackle something with more moving parts like the MCMC class, but for now I agree.
- Repr changes make sense.
- Stdout file is good. We may want to also do the same thing with diagnostic files and profiling files, which gets us closer to just including the whole Runset again... maybe we have a small dataclass that is like a cut-down runset and stores the relevant files, can be re-used across all of them?
- I agree, it makes sense to replace
csvwithoutputacross the board, both in the save functions and thefrom_csvfunction. This would make sense going forward anyway, c.f. stan-dev/design-docs#58 - Yep, totally agree that InferenceMetadata might want to take a hike when all is said and done. The header could be stored in each class, or in the cut-down-Runset idea ("
FileInfo"?)
| f'found {len(csvfiles)}' | ||
| ) | ||
| csv_file = csvfiles[0] | ||
| config_file = os.path.splitext(csv_file)[0] + '_config.json' |
There was a problem hiding this comment.
I assume this is temporary but it's worth putting a comment saying so, since this is kind of a nasty assumption
There was a problem hiding this comment.
Yeah definitely temporary. I was planning on doing away with this function entirely once the full changes are in.
|
@WardBrian Working through some of the other methods. For I see we could maybe parse that info out of the |
|
Hm, that is indeed tricky. I think we should add the piece of information that currently ends up in stdout ( Looking at the code, it looks like the only place we actually check the return codes is in model.py, before we call the CmdStanMLE constructor, so maybe it is fine? |
|
Yeah. In my local implementation, I take It does seem like the kind of thing that should be available in the CSV? Like a |
This is true for the existing |
|
Yeah good point, this is the snippet from for i in range(len(runset._retcodes)):
runset._set_retcode(i, 0)
return CmdStanMLE(runset)Looks like in recreating the runset, we just manually set all return codes to 0, which is just assuming convergence I believe. |
|
Yeah, so we won't be doing any worse, at least! |
|
@WardBrian Is there an equivalent to saving the metric json for the variational method? Looking at CmdStanVB now and we parse the variational eta value from the stancsv comments, but afaik we don't have an alternative way to source it for now. |
|
There's no preconditioning in our two variational inference implementations, ADVI and Pathfinder, so there's no equivalent of a metric/mass matrix/preconditioner. |
|
@bob-carpenter I believe @amas0 was asking just if we wrote the metadata from the algorithm as a json, similar to the metric in sampling, not if there was actually a preconditioner Unfortunately to my knowledge the answer to this is no. It's a shame to have to keep all the comment-reading machinery around for something as rarely-needed as the final eta value from ADVI... |
|
Sorry for the confusion. Keeping things around for A better approach for |
|
Yeah, in particular, I was referring to this section that appears in the output CSV if you run the variational method: lp__,log_p__,log_g__,theta
# Stepsize adaptation complete.
# eta = 0.1
0,0,0,0.5838444I just meant that we output the algorithm stepsize info to the CSV in the same way that we write the sampler adaptation output (as comment lines after the column headers). But in the sample case, we can output equivalent info to a metric JSON file, but that doesn't seem to be the case for the variational method here. So, I guess the question is what do we want to do with this in the near term? I think it makes sense to have it be an additional column as Bob suggested? I suppose the question is whether it is important enough to maintain the comment parsing in the near term or should we drop keeping track of it for now until an alternative source is available? |
|
I'm not sure it makes sense as a column, since the advi output is only the one row of the varational parameters, in which eta has already been adapted, and then forward sampling iterations Github code search doesn't seem to find any public code which actually calls Maybe we preserve a dumber version of comment parsing just for that, i.e. a function that just does for line in file:
if line.startswith("# eta ="):
return float(line.split('=')[1])Rather than all the present machinery? We can also try to add a structured/json output like for the metric, but that would take longer to thread through the cmdstan code and we'd still need something in the meantime |
Latent dynamics and profile output are also CSVs written next to the draws, so reading a directory produced with save_latent_dynamics or save_profile counted them as extra chains and failed with a mismatch between the number of chains and the number of metric files.
Chains were always labelled 1..N, so a run with explicit chain_ids came back with the wrong ids and selecting a chain from the rebuilt fit gave a different chain than in the original. CmdStan records the id in each config; a single-process run has one config holding the first chain's id, with the remaining chains following on from it.
7ff5c22 to
8437240
Compare
StanConfig[X] parametrized generics have no importable module-level name, so instances could not be pickled. Four fit classes worked around this with __getstate__/__setstate__ hacks that serialized the config as a dict; CmdStanPathfinder and CmdStanLaplace were missing the workaround and could not be pickled at all. Introduce named subclasses (SampleRunConfig, OptimizeRunConfig, etc.) with a from_json constructor, use them throughout the fit classes, and delete the per-class pickle workarounds. parse_config now only handles the auto-detect case used by from_output_files. Adds pickle round-trip tests for pathfinder and laplace fits.
Introduce stanfit/base.py with StanFit, holding the members every fit container duplicates (stan_variables, attribute access, pickling, column_names, save_output_files), and SingleFileFit, holding the common behavior of fits whose draws live in one Stan CSV file (lazy draw loading, draws, stan_variable, method_variables, create_inits, and the from_files parsing boilerplate). save_output_files gains the writability check and missing-file handling previously only present in CmdStanMCMC, applied uniformly via the _FILE_ATTRS class attribute. CmdStanPathfinder is the first class ported; it now contains only its method-specific members (from_files, is_resampled, repr). The other fit classes will follow in subsequent commits. Adds a save_output_files regression test for pathfinder.
Keeps only the Laplace-specific members: the mode attribute and its resolution in from_files, draws_pd, draws_xr, and repr. Draw loading, variable access, create_inits, pickling, and save_output_files are inherited. laplace.py shrinks from 358 to 161 lines.
The variational mean and sample are now views of the first and remaining rows of the inherited draws array rather than separate fields, and the eta comment-parse happens in the _assemble override. Keeps the VB-specific members: eta, the variational_params and variational_sample accessors, the mean/sample toggle on stan_variable(s), create_inits over the sample, and repr. vb.py shrinks from 322 to 213 lines.
The MLE and saved iterations are now views of the last row and the whole of the inherited draws array rather than separate fields; the base _assemble is used unchanged. The repeated convergence and save_iterations warnings are factored into small helpers, and the pd/dict accessors are derived from the np ones. Keeps the MLE-specific members: converged, the optimized_* accessors, the iterations toggle on stan_variable(s), create_inits from the estimate, and repr. mle.py shrinks from 361 to 232 lines.
Move draws() from SingleFileFit down to CmdStanPathfinder and CmdStanLaplace. Inheriting it on CmdStanVB and CmdStanMLE would have exposed the raw CSV rows, where the first row is the variational mean and the rows are optimizer iterations respectively; too easy to mistake for a posterior sample. method_variables remains shared.
MultiChainFit holds the shared behavior of fits with one Stan CSV file per chain: the chains property, list-normalizing from_files parsing, and cross-chain config validation, with a _comparable_config hook that subclasses extend with the settings that affect their draw layout. CmdStanGQ keeps only its GQ-specific members: previous_fit and the draw accessors that merge it with the generated quantities. Its config validation needs were exactly the base ones, so it vanishes entirely. gq.py shrinks from 815 to 668 lines.
Keeps the sampler-specific members: metric handling, warmup/draw count validation, divergence and treedepth tabulation, summary and diagnose, the draws accessors, and chain-aware create_inits. The cross-chain config validation reduces to a _comparable_config override extending the base checks with num_samples, num_warmup, save_warmup, and thin. The metric, diagnostic, and profile file lists join the shared save_output_files via _FILE_ATTRS. mcmc.py shrinks from 1014 to 842 lines.
The refactor onto shared StanFit base classes moved many public methods (stan_variables, save_output_files, create_inits, etc.) into the bases, so the api docs need :inherited-members: to keep rendering them on each fit class. The base classes themselves stay out of the documented API. Also clarifies why cross-chain config validation parses sibling configs with the class of the held config.
InferenceMetadata.from_csv read the entire Stan CSV, draws included, just to take its header line; a scan_header helper stops at the first non-comment line instead. from_output_files now raises for generate_quantities outputs rather than warning and returning None, which removes None from its return type
|
Okay, I think I'm happy enough with where this stands to have it ready for review. At the very least to have someone else other than me look at this. |
|
@amas0 it is on my list for this week! |
WardBrian
left a comment
There was a problem hiding this comment.
Starting small, really only reviewed a few pieces for now and decided to stop with my questions
| ) | ||
|
|
||
|
|
||
| def _sidecar(csv_file: Path, kind: str) -> Path: |
There was a problem hiding this comment.
[nit] don't love the name, maybe "_accompanying_json" or something?
| named for its first chain. Outputs written by running CmdStan directly | ||
| with a single output name and ``num_chains`` follow a different naming | ||
| scheme and are not currently supported. |
There was a problem hiding this comment.
Is this comment correct? Because that will definitely be a blocker, we don't want to drop support for the built-in parallelism
There was a problem hiding this comment.
To clarify, this refers specifically to if you run a model with built-in parallelism manually in cmdstan without cmdstanpy at all and then try to load it in to cmdstanpy. When you run it via cmdstanpy, we set the output filenames in the cmdstan call to conform to an expected naming convention.
As long as you are trying to load in a cmdstan output that was run via cmdstanpy, you will be fine.
| Instantiate a CmdStan object from a the Stan CSV files from a CmdStan run. | ||
| CSV files are specified from either a list of Stan CSV files or a single | ||
| Instantiate a CmdStan object from the output files of a CmdStan run. | ||
| Output files are specified as either a list of Stan CSV files or a single |
There was a problem hiding this comment.
I feel like we should allow the _config.json to be in the list, and use that if so rather than going looking for it?
| thin=thin, | ||
| save_warmup=save_warmup, | ||
| if method_name == 'sample': | ||
| # Collect the sidecar JSONs that exist next to each CSV. A |
There was a problem hiding this comment.
I think the word sidecar is the new em-dash. I almost never see it outside llm comments/thought traces (unless you count by bartenders)
There was a problem hiding this comment.
Yeah...I had a similar reaction. I satisfied myself when I saw it was apparently a thing, but I can clean that bit up.
| builder = _SINGLE_FILE_METHODS.get(method_name) | ||
| if builder is None: |
There was a problem hiding this comment.
I don't know if we really have enough of these to justify this over just an if/else chain or a match statement
| CmdStanPathfinder, | ||
| CmdStanVB, | ||
| from_csv, | ||
| from_output_files, |
There was a problem hiding this comment.
It's unfortunate we don't have a release in the 1.x cycle that formally deprecated from_csv, we might want to keep it around as an alias as a result. Thoughts?
There was a problem hiding this comment.
My approach with this PR was to specifically target a 2.0 release, which is why I opted for a clean compatibility break here. That being said an alias that replicates the previous experience of using from_csv probably wouldn't be an issue?
There was a problem hiding this comment.
General comment: I really like how the changes look in this file, to me it really motivates the rest of the changes to be able to have this all be nicer!
|
@WardBrian you've rightfully picked at the code around file discovery so far. I wanted to raise this point I made in PR description (in case you missed it) to get your thoughts:
I had a working version of a change where we essentially start from the config.json, which has enough information in it to reconstruct the output file names without needing to crawl around the directory (I believe). The current structure we have goes through a few heuristics to identify which CSVs are draws (vs diagnostics/profile) and to resolve which chain id each draw csv corresponds to. Starting from a config(s) would remove that step -- it would also enable easier support for the case where you ran cmdstan outside of cmdstanpy and are trying to load fit in (related to your comment above). It would also handle the case where there are multiple fits in the same directory (which is a current problem area if you provide a directory). The big drawback is the potential brittleness of the path references in the config. If a fit is generated and the output files are moved, then those references are no longer correct. One mitigation there would be to have the stanfit's We do now require a config to be present to construct a stanfit, so I don't think it's too crazy to consider this route, but it would certainly be a shift from past behavior. |
|
If I recall correctly, in the multi threaded case the output field in the config.json is still not a literal valid path, but instead the “template” without the chain id. Is that not the case? |
|
Ah yeah, I see - that's how it is for cmdstan directly. I had confused myself with how we run it through cmdstanpy. In these changes I take advantage of the 2.37 output file csv to have full paths for each chain csv in the config. |
|
Ah, because we now pass the names we’d like as a comma separated list? That’s nice |
|
Yeah, it makes it so that if we run a model via cmdstanpy then we get a pretty clean output set. I think it was also necessary to maintain the naming convention discussed in #835 now that we have the config in the mix? The underlying tension here I think is between whether the output files were generated by cmdstan directly or indirectly via cmdstanpy. In the latter case, we get a good amount of control to have a clean output to work with. In the former case, it's trickier. |
|
How about:
Reasonable? |
|
I'm good with that. |
| @@ -55,16 +55,30 @@ def test_laplace_from_csv() -> None: | |||
| assert isinstance(fit3.mode, cmdstanpy.CmdStanMLE) | |||
|
|
|||
|
|
|||
| def test_laplace_pickle_ability() -> None: | |||
There was a problem hiding this comment.
Should this be with the others in test_compliance?
PR intending to close #785 and implementing ideas discussed in #848.
Okay. I'll start by saying that the line diff is highly inflated due to needing to replace/generate large numbers of test files to be compatible with the new output files that are being consumed. The actual diff in the core code is:
So still a major PR, but not as insane as the top-line numbers look.
This is a major refactor of how stanfit objects are structured and constructed. The major points are:
etavalue is the only survivor). Each method now has a typed Pydantic object to validate/parse the config JSON and is stored infit.config.RunSets. The stanfit objects are all now dataclasses constructed directly from the output files of the cmdstan process. This relegates RunSet's role to constructing the cmdstan argument/output file name patterns.CmdStanMLEtakes aconvergedargument on construction sourced from the retcodes of the process (assumed true when from files).StanFitbase class which collects a good chunk of code that was getting repeated. This breaks down further intoSingleFileFit(MLE/VB/Laplace/Pathfinder) andMultiChainFit(Sample/GQ).from_csvis nowfrom_output_filessave_csvfileis nowsave_output_filesfit.runsetis gonefrom_output_files, we require config JSONs next to the CSV filesAlso, since we are on version 2.39 now it seemed a good time to bump the minimum version to 2.37 (this simplified some things).
There are some things that I think can still be improved, but this PR is already close to getting out of hand and I'd like to defer those to try to focus on getting this one "done". Just as note, these are:
stanfit/__init__.pybothers me. When we pulled everything out of a CSV, we didn't really have to worry about it.That being said, I think the solution here might be to promote the config to be the primary file for resolving the others. It includes explicit references to other output files and a proper Stanfit could be built starting from it. Right now we have to crawl around the output dir to find the appropriate output files. The config file, since it is generated by cmdstan, should always have accurate references to the file locations (provided they hadn't been moved).
Would really appreciate some thoughts on these changes @WardBrian, especially if any of these changes would cause issues for how this library is used with dev versions of cmdstan.
Disclaimer: AI was used throughout this PR -- critically for helping with regenerating the appropriate test files and applying initial prototype structures across the different methods. All code in this PR was reviewed by me before marking it as ready.