Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ForceField allows multiple parameters to have the same SMIRKS patterns #1363

Closed
mattwthompson opened this issue Aug 5, 2022 · 19 comments · Fixed by #1852
Closed

ForceField allows multiple parameters to have the same SMIRKS patterns #1363

mattwthompson opened this issue Aug 5, 2022 · 19 comments · Fixed by #1852

Comments

@mattwthompson
Copy link
Member

Describe the bug
The toolkit's force field class allows multiple parameters within the same handler to have the same SMIRKS patterns. While it may seem like a strange use case, the inclusion of TIP3P in Sage makes this an issue anytime somebody wishes to use a different water model alongside it. I'd argue this state should not be reached and somewhere an exception should be raised when attempting to add a parameter with a smirks already found in the handler's list of parameters.

This also causes issues downstream when we assume this sort of 1:1 mapping, which I think we should be able to get away with.

To Reproduce

Using the file at openff/toolkit/data/test_forcefields/tip3p.offxml:

>>> from openff.toolkit import ForceField
>>> import pprint
>>> ff = ForceField("openff-2.0.0.offxml", "tip3p.offxml")
>>> pprint.pprint(ff['vdW'].parameters[-4:])
[<vdWType with smirks: [#1]-[#8X2H2+0:1]-[#1]  epsilon: 0.1521 kilocalorie / mole  id: n-tip3p-O  sigma: 3.1507 angstrom  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 0.0 kilocalorie / mole  id: n-tip3p-H  sigma: 1 angstrom  >,
 <vdWType with smirks: [#1]-[#8X2H2+0:1]-[#1]  epsilon: 0.635968 kilojoules_per_mole  id: n1  sigma: 0.3150752406575124 nanometer  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 0 kilojoules_per_mole  id: n2  sigma: 1 nanometer  >]
>>> assert ff['vdW'].parameters[-1].smirks == ff['vdW'].parameters[-3].smirks
>>> assert ff['vdW'].parameters[-2].smirks == ff['vdW'].parameters[-4].smirks

Output
We ran into a case in which ForceField("openff-2.0.0.offxml", "tip4p.offxml") ultimately assigned TIP3P vdW parameters (from Sage) where I expected TIP4P vdW parameters to be assigned. This caused issues since other parameters (i.e. virtual sites) came from TIP4P and the result was an invalid mismatch of water models that unfortunately ran well enough to hide this error for weeks.

Computing environment (please complete the following information):

Python 3.9, macOS, most recent development head (0f1611f)

@j-wags
Copy link
Member

j-wags commented Aug 8, 2022

This seems at least undesired to me, and is probably a bug. I started to fix this in the ParameterList class, but realized that VirtualSites are one case where identical SMIRKS should be allowed, and so fixing this may be just a hair more complex than I thought. I'll hold off on this until some time in the future when I have more bandwidth.

@mattwthompson
Copy link
Member Author

I think overlapping SMIRKS is a dangerous footgun and one that's not going to go away as long as we explicitly support loading multiple force fields in and TIP3P parameters are in Sage.

I'd be happy with at least either:

  • Enforce uniqueness based on SMIRKS in all parameter handlers but VirtualSiteHandler
  • Create new parameter uniqueness check somewhere in ParameterHandler or ParameterList and make it simple SMIRKS uniqueness for most parameter handlers but a more complex function for VirtualSiteHandler

@jchodera
Copy link
Member

The whole concept of SMIRNOFF as a hierarchical format is that the "last match wins" rule means it is totally fine if we have a tree branch that is entirely overridden by a leaf with the same SMIRKS pattern. This is even desirable if we want to manually test an override or shoehorn in other parameter sets at the end, as you note. It may not be clean, but it feels like checking for duplicates is more of a "linting" process than something that should cause an error.

@mattwthompson
Copy link
Member Author

It would be useful if we could reproduce ForceField("openff-2.0.0.offxml", "tip4p.offxml") giving TIP3P charges to water; I don't recall if that happened or if it was just something we were worried about happening. Either way it was a mild surprise to us that such a state could be reached.

@mattwthompson
Copy link
Member Author

We ran into this again, a case in which a user sensibly tried to create different parameters with identical SMIRKS, trying to apply "up" and "down" virtual sites from the same triplet of orientation atoms. The toolkit, as we might expect, only applied one.

Here's another reproduction I wrote up before discovering I've already been burned by and reported this behavior:

In [1]: from openff.toolkit import *

In [2]: !cat duplicates.offxml
<?xml version="1.0" encoding="utf-8"?>
<SMIRNOFF version="0.3" aromaticity_model="OEAroModel_MDL">
    <vdW version="0.3" potential="Lennard-Jones-12-6" combining_rules="Lorentz-Berthelot" scale12="0.0" scale13="0.0" scale14="0.5" scale15="1.0" cutoff="9.0 * angstrom ** 1" switch_width="1.0 * angstrom ** 1" method="cutoff">
        <Atom smirks="[#11:1]" epsilon="0.111 * kilocalorie_per_mole ** 1" sigma="3.0 * angstrom ** 1"></Atom>
        <Atom smirks="[#11:1]" epsilon="0.222 * kilocalorie_per_mole ** 1" sigma="4.0 * angstrom ** 1"></Atom>
        <Atom smirks="[#17:1]" epsilon="0.333* kilocalorie_per_mole ** 1" sigma="3.1 * angstrom ** 1"></Atom>
        <Atom smirks="[#17:1]" epsilon="0.444* kilocalorie_per_mole ** 1" sigma="1.1 * angstrom ** 1"></Atom>
    </vdW>
    <Electrostatics version="0.4" scale12="0.0" scale13="0.0" scale14="0.8333333333" scale15="1.0" cutoff="9.0 * angstrom ** 1" switch_width="0.0 * angstrom ** 1" periodic_potential="Ewald3D-ConductingBoundary" nonperiodic_potential="Coulomb" exception_potential="Coulomb"></Electrostatics>
    <LibraryCharges version="0.3">
        <LibraryCharge smirks="[#11:1]" charge1="1.0 * elementary_charge ** 1"></LibraryCharge>
        <LibraryCharge smirks="[#17:1]" charge1="-1.0 * elementary_charge ** 1"></LibraryCharge>
    </LibraryCharges>
</SMIRNOFF>

In [3]: ForceField("duplicates.offxml").create_interchange(
   ...:     Molecule.from_smiles("[#11+1].[#17-1]").to_topology()
   ...: )["vdW"].potentials

Out[3]:
{PotentialKey associated with handler 'vdW' with id '[#11:1]': Potential(parameters={'sigma': <Quantity(3.0, 'angstrom')>, 'epsilon': <Quantity(0.111, 'kilocalorie_per_mole')>}, map_key=None),
 PotentialKey associated with handler 'vdW' with id '[#17:1]': Potential(parameters={'sigma': <Quantity(3.1, 'angstrom')>, 'epsilon': <Quantity(0.333, 'kilocalorie_per_mole')>}, map_key=None)}

@jpthompson17
Copy link

When constructing a ForceField, is it still true that

If multiple files are specified, any top-level tags that are repeated will be merged if they are compatible, with files appearing later in the sequence resulting in parameters that have higher precedence.

? Looking at this commit, in particular, the line

parameter = parameter_handler.get_parameter({"smirks": smirks})[0]

it looks as if Interchange follows "first match wins" rather than "last match wins".

@mattwthompson
Copy link
Member Author

That comment refers to how parameters are loaded up into the ForceField contents; once those objects are created Interchange has effectively a read-only view into them, both their contents and the results of SMIRKS-based parameter assignment. The toolkit still follows "last match wins" when running pattern-matching as long as SMIRKS patterns are not literally the same. When they are identical (this particular bug/quirk) some funky things can happen:

$ diff tip3p.offxml tip3p_modified.offxml
4,5c4,5
<         <Atom smirks="[#1]-[#8X2H2+0:1]-[#1]" epsilon="0.1521 * kilocalorie_per_mole ** 1" id="n-tip3p-O" sigma="3.1507 * angstrom ** 1"></Atom>
<         <Atom smirks="[#1:1]-[#8X2H2+0]-[#1]" epsilon="0.0 * kilocalorie_per_mole ** 1" id="n-tip3p-H" sigma="1.0 * nanometer ** 1"></Atom>
---
>         <Atom smirks="[#1]-[#8X2H2+0:1]-[#1]" epsilon="100.1521 * kilocalorie_per_mole ** 1" id="n-tip3p-O" sigma="3.1507 * angstrom ** 1"></Atom>
>         <Atom smirks="[#1:1]-[#8X2H2+0]-[#1]" epsilon="100.0 * kilocalorie_per_mole ** 1" id="n-tip3p-H" sigma="1.0 * nanometer ** 1"></Atom>

The toolkit will gladly load them up side-by-side, breaking some of the assumptions in lookup logic:

In [1]: from openff.toolkit import Molecule, ForceField

In [2]: force_field = ForceField("tip3p.offxml", "tip3p_modified.offxml")

In [3]: force_field['vdW'].parameters
Out[3]:
[<vdWType with smirks: [#1]-[#8X2H2+0:1]-[#1]  epsilon: 0.1521 kilocalorie_per_mole  id: n-tip3p-O  sigma: 3.1507 angstrom  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 0.0 kilocalorie_per_mole  id: n-tip3p-H  sigma: 1.0 nanometer  >,
 <vdWType with smirks: [#1]-[#8X2H2+0:1]-[#1]  epsilon: 100.1521 kilocalorie_per_mole  id: n-tip3p-O  sigma: 3.1507 angstrom  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 100.0 kilocalorie_per_mole  id: n-tip3p-H  sigma: 1.0 nanometer  >]

The way Interchange looks up parameters now is wired through ParameterList.__getitem__, which will return the first parameter with that specific SMIRKS pattern. That step in Interchange is just looking up parameter values from the toolkit using SMIRKS patterns as identifiers, not running the actual pattern-matching code which is meant to run bottom-to-top (and I believe still does). That happens here. With the arguably-invalid force field I have loaded in memory above, Interchange will ask the toolkit to run pattern-matching, it will store the SMIRKS pattern last in that ParameterList, and when looked-up it will feed it back the first value in that list. Because of the SMIRKS collision, it looks like an incorrect result:

In [4]: force_field.create_interchange(Molecule.from_smiles("O").to_topology())['vdW'].get_system_parameters(
   ...: )

Out[4]:
array([[3.1507, 0.1521],
       [1.    , 0.    ],
       [1.    , 0.    ]])

But all of that is only when multiple SMIRKS patterns are identical. In the common case of different files having different SMIRKS patterns, they're squished together and behave in the way the spec describes. For example, if I modify the SMIRKS in a way that will still match the oxygen in water but give it bogus values

$ diff tip3p.offxml tip3p_modified_smirks.offxml
4c4
<         <Atom smirks="[#1]-[#8X2H2+0:1]-[#1]" epsilon="0.1521 * kilocalorie_per_mole ** 1" id="n-tip3p-O" sigma="3.1507 * angstrom ** 1"></Atom>
---
>         <Atom smirks="[#8:1]" epsilon="-5* kilocalorie_per_mole ** 1" id="foo" sigma="-5 * angstrom ** 1"></Atom>

and feed it in as the last force field, those bogus values are what get carried through

In [1]: from openff.toolkit import Molecule, ForceField

In [2]: force_field = ForceField("tip3p.offxml", "tip3p_modified_smirks.offxml")

In [3]: force_field['vdW'].parameters
Out[3]:
[<vdWType with smirks: [#1]-[#8X2H2+0:1]-[#1]  epsilon: 0.1521 kilocalorie_per_mole  id: n-tip3p-O  sigma: 3.1507 angstrom  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 0.0 kilocalorie_per_mole  id: n-tip3p-H  sigma: 1.0 nanometer  >,
 <vdWType with smirks: [#8:1]  epsilon: -5 kilocalorie_per_mole  id: foo  sigma: -5 angstrom  >,
 <vdWType with smirks: [#1:1]-[#8X2H2+0]-[#1]  epsilon: 0.0 kilocalorie_per_mole  id: n-tip3p-H  sigma: 1.0 nanometer  >]

In [4]: force_field.create_interchange(Molecule.from_smiles("O").to_topology())['vdW'].get_system_parameters(
   ...: )

Out[4]:
array([[-5., -5.],
       [ 1.,  0.],
       [ 1.,  0.]])

To hammer the point home (that this issue only arises when SMIRKS patterns are identical) I can load the bogus file first and its values aren't propagated through:

In [5]: ForceField("tip3p_modified_smirks.offxml", "tip3p.offxml").create_interchange(Molecule.from_smiles("O
   ...: ").to_topology())['vdW'].get_system_parameters()
Out[5]:
array([[3.1507, 0.1521],
       [1.    , 0.    ],
       [1.    , 0.    ]])

I still think this behavior could be improved but I don't think it's a systematic issue with our shipped force fields - maybe if folks are squishing together force fields that must use identical SMIRKS patterns this is something we need to prioritize more highly.

@jpthompson17
Copy link

jpthompson17 commented Nov 9, 2023

Thanks for the detailed response, @mattwthompson. If I understand correctly, the comment about precedence only applies to parameters with the same tag name and unique SMIRKS patterns.

When parameters within the same handler have the same SMIRKS pattern, it appears that pre- and post-Interchange versions of the toolkit break ties differently: Interchange breaks ties by choosing the first. As you pointed out, this happens in the store_potentials() method of SMIRNOFFCollection subclasses. Meanwhile, toolkit <0.11 breaks ties by choosing the last. This happens in the deprecated create_force() method of ParameterHandler subclasses, which would call find_matches() and then use match.parameter_type directly. (In Interchange, store_matches() just uses the smirks attribute of match.parameter_type to create a potential key.)

To me, the old tie breaking behavior feels like a more natural extension of the comment about precedence. The current behavior seems to require a caveat, something like

If multiple files are specified, any top-level tags that are repeated will be merged if they are compatible, with files appearing later in the sequence resulting in parameters that have higher precedence, provided the parameters have unique SMIRKS patterns. If a parameter is encountered that has the same tag name and SMIRKS pattern as a previously read parameter, it will be ignored (i.e., it will not override the previously read parameter).

@mattwthompson
Copy link
Member Author

I agree with your assessment of the problem - I can't I've thought deeply about assignment with duplicate SMIRKS patterns prior to your comment, so thanks for catching this!

Between "pick first" (current) and "pick last" (old) I also agree that the old behavior is conceptually more consistent with the SMIRNOFF ethos. I think I'd prefer a different solution, though, in which the toolkit errors out when it detects duplicate SMIRKS (in the same tag). I'm skeptical it's a well-designed state1 so, even though the patch to Interchange might be small, I'm not sure this is a fruitful path to go down. This is a weakly-held opinion, though, and I don't think it would take much (from you or others) for me to budge on this.

Footnotes

  1. Of course I could be wrong - is there a use case you have in mind in which the same tag section should safely have multiple parameters with precisely identical SMIRKS? Presumably this would arise after smooshing some different force fields together, and (intentionally) don't do that much.

@bxie4
Copy link

bxie4 commented Nov 13, 2023

I'm currently in a scenario where there is a possibility of having multiple force field files, with individuals having optimized or fine-tuned certain parameters for specific purposes. These files may contain identical SMIRKS patterns, and we'd like to adhere to the "last match wins" principle while also keeping the behavior consistent with the previous version (toolkit <0.11). One potential solution is to : forcefield_files_sources = list(reversed(forcefield_files_sources)) + forcefield_files_sources and then ff = ForceField(*forcefield_files_sources, allow_cosmetic_attributes=True)
Enumerating the files both backward and forward ensures that the one listed last will take precedence. I'm wondering what you think of using forcefield_files_sources as the sources being loaded in ForceField.
I'm open to suggestions and feedback on this proposal.
Many thanks!

@mattwthompson
Copy link
Member Author

I'm afraid I don't understand this proposal (I don't know what forcefield_files_sources is.) I think I understand the use case, but without concrete code demonstrating the behavior it's hard to drill down into the details. Are you suggesting a change to the source code of the toolkit? If so, do you mind opening a PR that implements this change? We'd be able to have a look at the impact of such a change.

I think this is a case in which the SMIRNOFF specification could provide more guidance1. The behavior of the "old" toolkit APIs is an accident, and while it may ultimately be the preferred behavior, the correct behavior is whatever this page says.

The process for updating it is described here, and we have mechanisms in place that enable us to turn around changes fairly quickly (weeks, not months)

Footnotes

  1. I personally think an error when SMIRKS patterns completely match is appropriate, and would advocate for a change that adds this to the spec.

@bxie4
Copy link

bxie4 commented Nov 14, 2023

Thank you for your quick reply, @mattwthompson! To clarify, the forcefield_files_sources is just a list of multiple force field OFFXML files - forcefield_files_sources = [ff_file1, ff_file2, ff_file3] , and I'm not suggesting a change to the source code itself. I'm just wondering if it's possible to load the force field files in this format that would allow us to maintain the precedence order. Thanks.

@mattwthompson
Copy link
Member Author

The toolkit can process multiple force field files at once, and the order they're passed matters. For tags that are common between files, the contents of the last file passed should take precedence ("last match wins"). You can read more here:

Multiple SMIRNOFF data sources (e.g. multiple OFFXML files) can be loaded in sequence. If these files each contain unique top-level tags (such as , , etc.), the resulting force field will be independent of the order in which the files are loaded. If, however, the same tag occurs in multiple files, the contents of the tags are merged, with the tags read later taking precedence over the parameters read earlier, provided the top-level tags have compatible attributes. The resulting force field will therefore depend on the order in which parameters are read.

https://openforcefield.github.io/standards/standards/smirnoff/#multiple-smirnoff-representations-can-be-processed-in-sequence

@bxie4
Copy link

bxie4 commented Nov 14, 2023

Thank you for your reply.

I understand that the contents of the last file passed should take precedence ("last match wins"). However, when the SMIRKS patterns are the same, the toolkit 0.14.4 will take the first parameter. I've read through the thread and understand that it may seem odd to have the same pattern.
We're currently dealing with some forcefield files that have the same pattern, however. We'd like to follow "last match wins", specifically, favoring the last parameter to take precedence, and explore the best way to achieve this.

In our case, we have force field files: ff_1, ff_2, ff_3, and ff_3's parameters are the most important. We load the forcefield files in the order[ff_1, ff_2, ff_3], and because ff_3 is the last one loaded, its parameters should take precedence.
However, due to some common SMIRK patterns between ff_1 and ff_3, ff_1's parameters are currently being used.
To ensure ff_3's parameters take precedence, above way is proposed: [ff_3, ff_2, ff_1, ff_1, ff_2, ff_3]. This order would guarantee that ff_3's parameters are applied last and have the highest priority.

I would like to ask for your expert opinion on this to see if there are any potential downsides or concerns if we load force field files in this way.
Thank you.

@jpthompson17
Copy link

jpthompson17 commented Nov 14, 2023

Following up on @bxie4 's comment, the basic question is whether, as a workaround, one could get the toolkit >=0.11 ForceField to behave like the toolkit <0.11 ForceField by loading the .offxml sources like this (assuming for this example that sources is a list):

sources = list(reversed(sources)) + sources
forcefield = ForceField(*sources)

The idea of listing the files backward and then forward is that it would make parameters with the same tag and SMIRKS pattern behave as in toolkit <0.11 ("pick last"), while preserving the correct precedence for parameters with different SMIRKS patterns ("last match wins"). This workaround might come with a small performance penalty, making this loop a bit slower.

Alternatively, if the plan is for future releases of the toolkit to raise an error when parameters have the same tag and SMIRKS pattern, we could anticipate this on our end by doing something like

for tagname in forcefield.registered_parameter_handlers:
    smirks_patterns = set()
    for parameter in forcefield[tagname]:
        if parameter.smirks in smirks_patterns:
            raise ValueError(f"detected multiple {tagname!r} parameters with SMIRKS pattern {parameter.smirks!r}")
        smirks_patterns.add(parameter.smirks)

.

The important thing is that we have well-defined behavior when a user provides parameters with the same tag and SMIRKS pattern, whether that behavior is "pick last" or "fail". We'd like to avoid "pick first" because we find it unintuitive.

@mattwthompson Which (if either) of the above approaches would you recommend?

@mattwthompson
Copy link
Member Author

The important thing is that we have well-defined behavior ...

Agree completely; unfortunately this is a situation in which the behavior is poorly-defined, so as much as we try to avoid behavior changes in the toolkit, it's exceedingly difficult to ensure continuity when there is no clear correct behavior.

Of those two options I'd certainly favor the latter if I was in the shoes of a downstream developer. I (as a matter of opinion) question whether or not multiple identical SMIRKS patterns is a valid state to handle in general, and without knowing much about your application I'd imagine it's unintentional or at least non-essential. The toolkit MIGHT1 have a change in the future that throws an error when it detects this, so I like the forwards-compatibility of that solution. If the first solution works as you describe, that's not necessarily bad, but you're right it might run into an error with a future version of the toolkit.

It's worth mentioning that @j-wags is the Captain Picard of the toolkit, but he is out of the office for approximately the rest of the month. I'm handling his duties all the way up to, but not including, behavior changes. Sometime after he's back at work, he'll be able to give an authoritative path forward here. If I come across as constrained it's because of that context - I want to communicate the ways that things might change but they won't happen for at least a few weeks, likely more like early 2024.

Footnotes

  1. This is, of course, a change I'm pushing for, but we'll need input from Jeff and/or the SMIRNOFF committee to have enough backing to do it. So, as much as I'd like to commit to doing it, the only guarantee that I can really make now is that the current behavior might change.

@jpthompson17
Copy link

Thanks, @mattwthompson . I understand the constraints with Picard on shore leave.

If we were to go with the latter approach, could we rely on the registered_parameter_handlers attribute sticking around at least through the rest of the v0.14 series (i.e., >=0.14.4, <0.15)? The docs warn that it is experimental.

@mattwthompson
Copy link
Member Author

Yes - that warning is somewhat a blanket descriptor of the toolkit. Removing ForceField.registered_parameter_handlers would be a hugely breaking change, something we wouldn't do without some warning.

In addition to that, we'll ensure that any changes to this behavior at least don't land before 0.15.0

@j-wags
Copy link
Member

j-wags commented Mar 29, 2024

Sorry for the big delay on this.

I think the right solution to the parameter lookup question is to flip the behavior here, so that parameter lookups prioritize the last match for a given SMIRKS instead of the first. So this would change the behavior of ParameterHandler and ParameterList lookups. The behavior ofParameterHandler.get_parameter would remain the same since that's capable of returning multiple results.

I think we should continue allowing multiple identical SMIRKS in the same parameter section. Loading an alternate water model on top of a flagship FF will be a pain otherwise, as will some use cases of virtual sites.

Virtual sites may have yet more issues (maybe they should be looked up by a tuple of SMIRKS and name?) but those can be handled separately.

I'll work on implementing this next week.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants