mabuchilab / QNET

Computer algebra package for quantum mechanics and photonic quantum networks
https://qnet.readthedocs.io/
MIT License
71 stars 23 forks source link

Subclass LocalSpace into FockSpace, SpinSpace #60

Closed danielwe closed 6 years ago

danielwe commented 7 years ago

Identifiers on LocalOperators are not preserved by simplification and substitution rules.

>>> from qnet.algebra import Destroy, OperatorTimes
>>> b = Destroy(identifier='b', hs=0)
>>> OperatorTimes.create(b, b.dag())
𝟙 + â^(0)† â⁽⁰⁾

Tying identifiers to particular instantiations of local operators might not be the best idea. The identifier should rather be a property of the corresponding local Hilbert space factor.

goerz commented 7 years ago

Let's remove the identifier from the constructor of all LocalOperator subclasses. We only keep the _identifier class attribute, as a constant.

To restore the ability have different identifiers in different Hilbert spaces when printing out expressions, let's add a keyword argument local_identifiers to the constructor of the LocalSpace class. This could receive a dict that maps LocalOperator subclass names to an identifier string. The call would be e.g.

hs = LocalSpace(1, dimension=10, local_identifiers={'Destroy': 'b',  'Squeeze': 'S'})

Then, when printing any LocalOperator, we'd look at the local_identifiers, with a fallback to the _identifier class attribute of the operator.

Note that the keys in local_identifiers need to be strings (not classes) to make sure that expressions can be serialized and compared correctly. Hilbert spaces that differ in the local_identifiers must be distinguishable (LocalSpace(1, local_identifiers={'Destroy': 'b'}) != LocalSpace(1))

Actually, I think we might want to remove the Create class, and replace it with a Create function that just returns Destroy(...).dag(). Otherwise, it's possible to end up with inconsistencies (you'd have to assign the same identifier to both 'Create' and 'Destroy')

danielwe commented 7 years ago

Radical idea: let local operators be attributes of a local space. Instantiate ls = LocalSpace(1), and there you have ls.destroy, ls.create etc. (with identifiers as given in your suggestion). Of course, retain Destroy(ls) etc. for backward compatibility, now as a function that returns ls.destroy. Haven't thought this through, is there an obvious reason why it's a terrible idea?

It might only make sense if we also go through with what we discussed about speciation amongst local spaces, such that a local space either supports bosonic operators or spin operators, not both at the same time.

goerz commented 7 years ago

I don't think that'll work (if I correctly understand what you're proposing): We can't get around having the proper class structure for any algebraic object. It's possible of course to have convenience functions like ls.destroy() that would be equivalent to Destroy(hs=ls), but not the other way around. Actually, the convenience function would probably be more something like ls.local_op('Destroy'), because ls.create is already the rule-applying constructor, and also because people might define additional local operators.

danielwe commented 7 years ago

Yes, the operators have to be instances of classes. And you're right, we need specific classes for specific types of operators such as Create and Destroy to facilitate algebra, so forget about replacing Destroy with a function.

I think the essence of what I'm proposing is to promote the Hilbert space algebra to play a more central role and make local operators subordinate to their respective spaces rather than the other way around. Currently, there is a single generic LocalSpace class on whose instances you can tack on arbitrary LocalOperators to sort-of define the physical system you're modeling. My idea is that a local degree of freedom should be an instance of a particular subclass of LocalSpace that defines a canonical basis for its own algebra of operators, and the only operators allowed to act on that space are algebraic combinations of these basis elements.

Consider the most elementary example, a single, continuous degree of freedom (the system that, given the right Hamiltonian, becomes a harmonic oscillator). Suppose we define it as class Boson(LocalSpace), an consider an instance cavity = Boson('label'). The obvious operator basis is the Harmonic oscillator creation and annihilation operators, which could be accessed through something cavity.create and cavity.destroy (I realize that the create name is taken, so we would have to come up with another name or solution). Now, trying to create a spin operator acting on this space, e.g., Jplus(hs=cavity), should fail, since the space does not contain the requisite degree of freedom. Meanwhile, Destroy(hs=cavity) just returns cavity.destroy, which is already an instance of the Destroy class (of course, it doesn't matter if it formally instantiates a new object that is equal to cavity.destroy, but it might as well return the same object). Any valid operator on cavity can be expressed in terms of cavity.create and cavity.destroy, e.g., x = cavity.create + cavity.destroy.

In other words, the operator algebra will still be defined by the operator class hierarchy as today, but local operators must be instantiated subordinate to a local space that explicitly restricts the kinds of operators that are valid on that space. The benefits are enforcing consistency and eliminating bugs like the one we're discussing, and perhaps also providing a safeguard against conceptual errors when building models (I don't have a specific example of this at the moment). I also think it provides a clearer mental model of local spaces and operators.

As always, composite systems are created by taking tensor products of elementary local spaces. Currently, the hierarchy below LocalOperator only supports three kinds of systems, bosonic systems, two-level systems, and spin systems, so it's not a big deal to write the corresponding subclasses of LocalSpace required to build any system supported today.

Does this make sense? Not saying that this is something we should definitely do, but would like to hear your thoughts and why it might be a bad idea.

goerz commented 6 years ago

Everything you say makes total sense. We should definitely create subclasses of LocalSpace, and associate specific operators with those subclasses. The way I see this being implemented is to say e.g. Jz(hs=SpinSpace(0, spin=2)). Instantiating Jz with something other than a SpinSpace would raise a TypeError. I think this is sufficient to make the operators "subordinate" to the correct space. I'm not sure I see a good way to instantiate operators from the Hilbert space directly: this would require to somehow register the operator classes with the Hilbert space (as we'd expect users to define their own custom operators).

Conceptually, our Hilbert spaces are already defined through their associated basis, which is implemented as a list of string labels. If no basis is defined, the basis states are only referenced through their integer index, as a fallback that still allows a number of algebraic manipulations. Instantiating the a LocalSpace without a dimension/basis, as e.g. just LocalSpace(0) is generally not the preferred approach. Especially when using QNET to drive a numerical simulation (converting to qutip), all Hilbert spaces have to have a well-defined basis (you can substitute a Hilbert space with a basis for on without a basis to achieve this). With an explicit dimension/basis, since all operators are ultimately expressed as ket-bras on that basis, when instantiating a LocalSpace, we thus already define the canonical basis and the possible operators. When we refine the LocalSpace into subclasses, all we do is place restrictions on the acceptable dimension and the basis labels.

In more detail I would propose the following:

  1. Create the following subclasses of LocalSpace: FockSpace, SpinSpace, TLSSpace (names are up for debate)

    a. FockSpace has no particular restrictions compared to the current LocalSpace. b. SpinSpace can be instantiated with a parameter "spin" (int or sympy.Rational; abbrev "s") instead of "dimension" which implies a dimension of 2s+1. If "spin" is given without an explicit list of basis labels, the default labels are "-{s}" ... "0" ... "+{s}". It would also be possible to use the labels "{s}, -{s}", "{s}, -{s+1}", ..., although this risks confusion with a ProductSpace c. TLSSpace has a fixed dimension of 2 (default labels "0", "1")

  2. Create matching subclasses of LocalOperator: FockOperator, SpinOperator, TLSOperator. All these do is check that they are instantiated with the correct HilbertSpace.

    The current Displace, Phase, Squeeze, Create, and Destroy classes become subclasses of FockOperator. Jz, Jminus, and Jplus become subclasses of SpinOperator. We might consider adding the Pauli operators as subclasses of TLSOperators (instead of a wrapper function expressing them as LocalSigma right now). We can delay the definition of TLSSpace until we actually need it. LocalSigma remains a direct subclass of LocalOperator, as it is conceptually the most general operator (In fact any operator can be written as a sum of LocalSigma)

  3. For Hilbert spaces which have no specified dimension or an explicit basis, integer indexes for the basis states can occur in the following places:

    • for j, k in the LocalSigma/LocalProjector constructor
    • index_j, index_k property of LocalSigma
    • parameter m of Jpjmcoeff, Jzjmcoeff, Jmjmcoeff (if shift=True)
    • HilbertSpace.basis_state
    • LocalSpace.next_basis_or_label
    • label_or_index in the BasisKet constructor

    For example, BasisKet(0, hs=0), BasisKet("0", hs=LocalSpace(0, dimension=2) and BasisKet(0, hs=LocalSpace('tls', basis=("g", "e"))) all refer to the first basis state of the Hilbert space, BasisKet("0", hs=LocalSpace(0, dimension=2)) and BasisKet("g", hs=LocalSpace('tls', basis=("g", "e"))) select a basis state by label. Note that BasisKet("0", hs=0) is invalid, as no basis has been set (or dimension, which would automatically generate a basis with labels "0", "1", ...)

    For a FockSpace and TLSSpace, the zero-based indexing of basis states is canonical and will not lead to any surprises. For the SpinSpace, there is a possible source of confusion for integer-values spins where e.g. for spin=2 the canonical labels would be "-2", "-1", "0", "1", "2": A user might accidentally say BasisKet(0, hs=SpinSpace(0, s=2)) with the intention to get BasisKet("0", hs=SpinSpace(0, s=2)), but would instead get BasisKet("-2", hs=SpinSpace(0, s=2)), due to the zero-based indexing.

    I was briefly considering to include an index offset in the SpinSpace class that would automatically shift indices such that the routines listed above when an integer index instead of a label is given. However, after some thought I think this is neither possible nor a good idea:

    • for non-integer spin, there are no integer labels
    • it might be just as confusing to deviate from the Python's standard zero-based list indexing for SpinSpaces as it is to get the "wrong" state by giving an integer index instead of a string label.

    Therefore, I think we should not change the semantics of the indexing at this point.

Does all of the above sound like a good plan?

goerz commented 6 years ago

After discussion with @danielwe, a few more comments:

  1. I was overstating the need to define a basis. Mathematically, any FockSpace is typically infinite-dimensional and we wouldn't instantiate it with a dimension or basis. Only when we want to do numerics we have to move to a truncated FockSpace.

  2. The documentation of LocalSigma should make it clear that is has nothing to do with two-level-systems, but is QNET's general way of saying |j><k| on any Hilbert space

  3. For convenience, the SpinSpace should also accept a string for spin which gets sympyfied:

    SpinSpace(0, spin='1/2')

    is equivalent to

    SpinSpace(0, spin=sympy.sympify('1/2'))
  4. It is better to not define a TLSSpace. The definition of the Pauli-Matrices as wrapper function expressing the operator in terms of LocalSigma should be kept. This allows to define a TLS from two different perspectives. Either as a FockSpace truncated to two levels (labels "0", "1"), or as SpinSpace(spin='1/2') (labels "-1/2", "1/2"). Different people prefer different conventions, and we can handle both at the same time. There should be a section in the documentation about TLS that talks about this explicitly.

goerz commented 6 years ago

After trying this out in practice, it turns out having FockSpace adds some unnecessary complications. Most importantly, it makes the creation of implicit Hilbert spaces (e.g. LocalSigma(0, 1, hs=1) more complicated. Also, for at least some of the "FockOperators", e.g. Phase, it's perfectly sensible to apply them to a spin state. Therefore, leaving LocalSpace as a generalized Fock space, and only having an additional SpinSpace that's a subclass of LocalSpace works better.