Repository: PennyLaneAI/pennylane
Pull request #5696 (2024-05-16 18:12:22)

Lazy loading of Quantumscript properties
**Context:**
Eager initialization of parameters inside the constructor of `QuantumScript` induces a high classical overhead.

**Description of the Change:**
The following attributes of `QuantumScript` are no longer initialized in the constructor, but only when needed:

- par_info
- wires, num_wires
- obs_sharing_wires, obs_sharing_wires_id

`par_info`, `obs_sharing_wires`, and `obs_sharing_wires_id` are now public attributes as they were used in several files outside the `QuantumScript` class.  Examples of this behaviour are: 

- pennylane/_qubit_device.py
- pennylane/pennylane/circuit_graph.py
- pennylane/pennylane/fourier/qnode_spectrum.py
- pennylane/pennylane/gradients/adjoint_metric_tensor.py
- pennylane/pennylane/gradients/gradient_transform.py
- pennylane/pennylane/transforms/tape_expand.py


The most expensive attributes: `par_info` and `wires ` are now a `@cached_property` to avoid calculating them every time they are needed. 

When used inside the `QuantumTape` context, the `_update()` method is called to invalidate the cached value and allow for recalculation. 


**Benefits:**
Reduce in the classical overhead of creating a `QuantumScript`. For instance, for the following code:

```python
n_wires = 5
n_layers = 50

dev = qml.device('null.qubit')

@qml.qnode(dev, diff_method="parameter-shift")
def circuit(params):
    qml.StronglyEntanglingLayers(params, wires=range(n_wires))
    return qml.expval(qml.PauliZ(n_wires-1))

shape = qml.StronglyEntanglingLayers.shape(n_wires=n_wires, n_layers=n_layers)
rng = np.random.default_rng(seed=42)
params = np.array(rng.random(shape))
qml.grad(circuit)(params)
```
The `QuantumScript` creation time decreases from 62.51% of the whole time, to 41.92%.

Related Shortcut Stories:
[sc-45973]



github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5696?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.53%. Comparing base [(`0dcb866`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/0dcb8669c2771fa6cb4456f8ded50562fce2b08c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`b315d80`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5696?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).
> Report is 3 commits behind head on master.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #5696      +/-   ##
==========================================
- Coverage   99.68%   99.53%   -0.15%     
==========================================
  Files         415      415              
  Lines       38878    38720     -158     
==========================================
- Hits        38754    38540     -214     
- Misses        124      180      +56     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/5696?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index c2525461f..c107e74e9 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -102,6 +102,10 @@
 * Empty initialization of `PauliVSpace` is permitted.
   [(#5675)](https://github.com/PennyLaneAI/pennylane/pull/5675)
 
+* `QuantumScript` properties are only calculated when needed, instead of on initialization. This decreases the classical overhead by >20%.
+  `par_info`, `obs_sharing_wires`, and `obs_sharing_wires_id` are now public attributes.
+  [(#5696)](https://github.com/PennyLaneAI/pennylane/pull/5696)
+
 <h4>Community contributions 🥳</h4>
 
 * Implemented kwargs (`check_interface`, `check_trainability`, `rtol` and `atol`) support in `qml.equal` for the operators `Pow`, `Adjoint`, `Exp`, and `SProd`.
@@ -197,6 +201,7 @@ Astral Cai,
 Ahmed Darwish,
 Isaac De Vlugt,
 Pietropaolo Frisoni,
+Emiliano Godinez,
 Soran Jahangiri,
 Korbinian Kottmann,
 Christina Lee,
diff --git a/pennylane/_device.py b/pennylane/_device.py
index 7c0695fd6..b089523f8 100644
--- a/pennylane/_device.py
+++ b/pennylane/_device.py
@@ -16,7 +16,6 @@ This module contains the :class:`Device` abstract base class.
 """
 # pylint: disable=too-many-format-args, use-maxsplit-arg, protected-access
 import abc
-import copy
 import types
 import warnings
 from collections import OrderedDict
@@ -81,7 +80,7 @@ def _local_tape_expand(tape, depth, stop_at):
             if isinstance(obj, Operator):
                 if obj.has_decomposition:
                     with QueuingManager.stop_recording():
-                        obj = QuantumScript(obj.decomposition(), _update=False)
+                        obj = QuantumScript(obj.decomposition())
                 else:
                     new_queue.append(obj)
                     continue
@@ -94,11 +93,9 @@ def _local_tape_expand(tape, depth, stop_at):
 
     # preserves inheritance structure
     # if tape is a QuantumTape, returned object will be a quantum tape
-    new_tape = tape.__class__(new_ops, new_measurements, shots=tape.shots, _update=False)
+    new_tape = tape.__class__(new_ops, new_measurements, shots=tape.shots)
 
     # Update circuit info
-    new_tape.wires = copy.copy(tape.wires)
-    new_tape.num_wires = tape.num_wires
     new_tape._batch_size = tape._batch_size
     new_tape._output_dim = tape._output_dim
     return new_tape
@@ -675,9 +672,9 @@ class Device(abc.ABC):
         comp_basis_sampled_multi_measure = (
             len(circuit.measurements) > 1 and circuit.samples_computational_basis
         )
-        obs_on_same_wire = len(circuit._obs_sharing_wires) > 0 or comp_basis_sampled_multi_measure
+        obs_on_same_wire = len(circuit.obs_sharing_wires) > 0 or comp_basis_sampled_multi_measure
         obs_on_same_wire &= not any(
-            isinstance(o, (Hamiltonian, LinearCombination)) for o in circuit._obs_sharing_wires
+            isinstance(o, (Hamiltonian, LinearCombination)) for o in circuit.obs_sharing_wires
         )
         ops_not_supported = not all(self.stopping_condition(op) for op in circuit.operations)
 
@@ -688,7 +685,6 @@ class Device(abc.ABC):
             circuit = _local_tape_expand(
                 circuit, depth=max_expansion, stop_at=self.stopping_condition
             )
-            circuit._update()
 
         return circuit
 
@@ -780,7 +776,7 @@ class Device(abc.ABC):
             circuits, hamiltonian_fn = qml.transforms.sum_expand(circuit)
 
         elif (
-            len(circuit._obs_sharing_wires) > 0
+            len(circuit.obs_sharing_wires) > 0
             and not hamiltonian_in_obs
             and all(
                 not isinstance(m, (SampleMP, ProbabilityMP, CountsMP)) for m in circuit.measurements
diff --git a/pennylane/_qubit_device.py b/pennylane/_qubit_device.py
index 7a55b1ed3..aa8cad8ca 100644
--- a/pennylane/_qubit_device.py
+++ b/pennylane/_qubit_device.py
@@ -1715,7 +1715,7 @@ class QubitDevice(Device):
         trainable_params = []
         for k in tape.trainable_params:
             # pylint: disable=protected-access
-            mp_or_op = tape[tape._par_info[k]["op_idx"]]
+            mp_or_op = tape[tape.par_info[k]["op_idx"]]
             if isinstance(mp_or_op, MeasurementProcess):
                 warnings.warn(
                     "Differentiating with respect to the input parameters of "
diff --git a/pennylane/fourier/qnode_spectrum.py b/pennylane/fourier/qnode_spectrum.py
index 1c4fcb159..6eff15145 100644
--- a/pennylane/fourier/qnode_spectrum.py
+++ b/pennylane/fourier/qnode_spectrum.py
@@ -409,7 +409,7 @@ def qnode_spectrum(qnode, encoding_args=None, argnum=None, decimals=8, validatio
         cjacs = jac_fn(*args, **kwargs)
         spectra = {}
         tape = qml.transforms.expand_multipar(qnode.qtape)
-        par_info = tape._par_info
+        par_info = tape.par_info
 
         # Iterate over jacobians per argument
         for jac_idx, cjac in enumerate(cjacs):
diff --git a/pennylane/gradients/adjoint_metric_tensor.py b/pennylane/gradients/adjoint_metric_tensor.py
index 96d893b7b..5c16528b5 100644
--- a/pennylane/gradients/adjoint_metric_tensor.py
+++ b/pennylane/gradients/adjoint_metric_tensor.py
@@ -40,7 +40,7 @@ def _group_operations(tape):
     ops = tape.operations
     # Find the indices of trainable operations in the tape operations list
     # pylint: disable=protected-access
-    trainable_par_info = [tape._par_info[i] for i in tape.trainable_params]
+    trainable_par_info = [tape.par_info[i] for i in tape.trainable_params]
     trainables = [info["op_idx"] for info in trainable_par_info]
     # Add the indices incremented by one to the trainable indices
     split_ids = list(chain.from_iterable([idx, idx + 1] for idx in trainables))
diff --git a/pennylane/gradients/gradient_transform.py b/pennylane/gradients/gradient_transform.py
index ca47e76aa..4b3331bb4 100644
--- a/pennylane/gradients/gradient_transform.py
+++ b/pennylane/gradients/gradient_transform.py
@@ -169,7 +169,7 @@ def _try_zero_grad_from_graph_or_get_grad_method(tape, param_index, use_graph=Tr
     """
 
     # pylint:disable=protected-access
-    par_info = tape._par_info[param_index]
+    par_info = tape.par_info[param_index]
 
     if use_graph:
         op_or_mp = tape[par_info["op_idx"]]
diff --git a/pennylane/gradients/hadamard_gradient.py b/pennylane/gradients/hadamard_gradient.py
index 02a6cdfef..ffeafb06a 100644
--- a/pennylane/gradients/hadamard_gradient.py
+++ b/pennylane/gradients/hadamard_gradient.py
@@ -342,12 +342,14 @@ def _expval_hadamard_grad(tape, argnum, aux_wire):
                     measurements.append(qml.probs(op=obs_new))
 
             new_tape = qml.tape.QuantumScript(ops=ops, measurements=measurements, shots=tape.shots)
-
             _rotations, _measurements = qml.tape.tape.rotations_and_diagonal_measurements(new_tape)
-            # pylint: disable=protected-access
-            new_tape._ops = new_tape.operations + _rotations
-            new_tape._measurements = _measurements
-            new_tape._update()
+            new_ops = new_tape.operations + _rotations
+            new_tape = qml.tape.QuantumScript(
+                new_ops,
+                _measurements,
+                shots=new_tape.shots,
+                trainable_params=new_tape.trainable_params,
+            )
 
             num_tape += 1
 
diff --git a/pennylane/gradients/parameter_shift_cv.py b/pennylane/gradients/parameter_shift_cv.py
index 4adbfad1f..2eb69e3f1 100644
--- a/pennylane/gradients/parameter_shift_cv.py
+++ b/pennylane/gradients/parameter_shift_cv.py
@@ -61,7 +61,7 @@ def _grad_method_cv(tape, idx):
             or ``"0"`` (constant parameter).
     """
 
-    par_info = tape._par_info[idx]
+    par_info = tape.par_info[idx]
     op = par_info["op"]
 
     if op.grad_method in (None, "F"):
@@ -330,7 +330,7 @@ def second_order_param_shift(tape, dev_wires, argnum=None, shifts=None, gradient
 
     for idx, _ in enumerate(tape.trainable_params):
         t_idx = list(tape.trainable_params)[idx]
-        op = tape._par_info[t_idx]["op"]
+        op = tape.par_info[t_idx]["op"]
 
         if idx not in argnum:
             # parameter has zero gradient
@@ -364,8 +364,8 @@ def second_order_param_shift(tape, dev_wires, argnum=None, shifts=None, gradient
         # evaluate transformed observables at the original parameter point
         # first build the Heisenberg picture transformation matrix Z
         Z0 = op.heisenberg_tr(dev_wires, inverse=True)
-        Z2 = shifted_tapes[0]._par_info[t_idx]["op"].heisenberg_tr(dev_wires)
-        Z1 = shifted_tapes[1]._par_info[t_idx]["op"].heisenberg_tr(dev_wires)
+        Z2 = shifted_tapes[0].par_info[t_idx]["op"].heisenberg_tr(dev_wires)
+        Z1 = shifted_tapes[1].par_info[t_idx]["op"].heisenberg_tr(dev_wires)
 
         # derivative of the operation
         Z = Z2 * coeffs[0] + Z1 * coeffs[1]
@@ -390,7 +390,7 @@ def second_order_param_shift(tape, dev_wires, argnum=None, shifts=None, gradient
 
         Z = B @ Z @ B_inv  # conjugation
 
-        g_tape = tape.copy(copy_operations=True)
+        new_measurements = list(tape.measurements)
         constants = []
 
         # transform the descendant observables into their derivatives using Z
@@ -419,9 +419,14 @@ def second_order_param_shift(tape, dev_wires, argnum=None, shifts=None, gradient
                     constant = A[0]
 
             constants.append(constant)
+            new_measurements[obs_idx] = qml.expval(op=_transform_observable(obs, Z, dev_wires))
 
-            g_tape._measurements[obs_idx] = qml.expval(op=_transform_observable(obs, Z, dev_wires))
-        g_tape._update_par_info()
+        g_tape = qml.tape.QuantumScript(
+            tape.operations,
+            new_measurements,
+            shots=tape.shots,
+            trainable_params=tape.trainable_params,
+        )
 
         if not any(i is None for i in constants):
             # Check if *all* transformed observables corresponds to a constant
diff --git a/pennylane/tape/operation_recorder.py b/pennylane/tape/operation_recorder.py
index 0aaa20357..bfbb63396 100644
--- a/pennylane/tape/operation_recorder.py
+++ b/pennylane/tape/operation_recorder.py
@@ -48,10 +48,9 @@ class OperationRecorder(QuantumScript, AnnotatedQueue):
         ops=None,
         measurements=None,
         shots=None,
-        _update=True,
     ):  # pylint: disable=unused-argument, too-many-arguments
         AnnotatedQueue.__init__(self)
-        QuantumScript.__init__(self, ops, measurements, shots, _update=_update)
+        QuantumScript.__init__(self, ops, measurements, shots)
         self.ops = None
         self.obs = None
 
diff --git a/pennylane/tape/qscript.py b/pennylane/tape/qscript.py
index ab9b1ba26..dbf9bc5f2 100644
--- a/pennylane/tape/qscript.py
+++ b/pennylane/tape/qscript.py
@@ -20,6 +20,7 @@ executed by a device.
 import contextlib
 import copy
 from collections import Counter
+from functools import cached_property
 from typing import List, Optional, Sequence, Union
 
 import pennylane as qml
@@ -30,9 +31,6 @@ from pennylane.queuing import AnnotatedQueue, process_queue
 from pennylane.typing import TensorLike
 from pennylane.wires import Wires
 
-_empty_wires = Wires([])
-
-
 OPENQASM_GATES = {
     "CNOT": "cx",
     "CZ": "cz",
@@ -84,8 +82,6 @@ class QuantumScript:
         shots (None, int, Sequence[int], ~.Shots): Number and/or batches of shots for execution.
             Note that this property is still experimental and under development.
         trainable_params (None, Sequence[int]): the indices for which parameters are trainable
-        _update=True (bool): Whether or not to set various properties on initialization. Setting
-            ``_update=False`` reduces computations if the script is only an intermediary step.
 
     .. seealso:: :class:`pennylane.tape.QuantumTape`
 
@@ -175,32 +171,19 @@ class QuantumScript:
         measurements=None,
         shots: Optional[Union[int, Sequence, Shots]] = None,
         trainable_params: Optional[Sequence[int]] = None,
-        _update=True,
     ):
         self._ops = [] if ops is None else list(ops)
         self._measurements = [] if measurements is None else list(measurements)
         self._shots = Shots(shots)
 
-        self._par_info = []
-        """list[dict[str, Operator or int]]: Parameter information.
-        Values are dictionaries containing the corresponding operation and operation parameter index."""
-
         self._trainable_params = trainable_params
         self._graph = None
         self._specs = None
         self._output_dim = None
         self._batch_size = _UNSET_BATCH_SIZE
 
-        self.wires = _empty_wires
-        self.num_wires = 0
-
-        self._obs_sharing_wires = []
-        """list[.Observable]: subset of the observables that share wires with another observable,
-        i.e., that do not have their own unique set of wires."""
-        self._obs_sharing_wires_id = []
-
-        if _update:
-            self._update()
+        self._obs_sharing_wires = None
+        self._obs_sharing_wires_id = None
 
     def __repr__(self):
         return f"<{self.__class__.__name__}: wires={self.wires.tolist()}, params={self.num_params}>"
@@ -395,41 +378,87 @@ class QuantumScript:
         """Update all internal metadata regarding processed operations and observables"""
         self._graph = None
         self._specs = None
-        self._update_circuit_info()  # Updates wires, num_wires; O(ops+obs)
-        self._update_par_info()  # Updates _par_info; O(ops+obs)
 
-        self._update_observables()  # Updates _obs_sharing_wires and _obs_sharing_wires_id
+        try:
+            # Invalidate cached properties so they get recalculated
+            del self.wires
+            del self.par_info
+        except AttributeError:
+            pass
 
-    def _update_circuit_info(self):
-        """Update circuit metadata
+    @cached_property
+    def wires(self) -> Wires:
+        """Returns the wires used in the quantum script process
 
-        Sets:
-            wires (~.Wires): Wires
-            num_wires (int): Number of wires
+        Returns:
+            ~.Wires: wires in quantum script process
         """
-        self.wires = Wires.all_wires(dict.fromkeys(op.wires for op in self))
-        self.num_wires = len(self.wires)
+        return Wires.all_wires(dict.fromkeys(op.wires for op in self))
 
-    def _update_par_info(self):
-        """Update the parameter information list. Each entry in the list with an operation and an index
-        into that operation's data.
+    @property
+    def num_wires(self) -> int:
+        """Returns the number of wires in the quantum script process
 
-        Sets:
-            _par_info (list): Parameter information
+        Returns:
+            int: number of wires in quantum script process
+        """
+        return len(self.wires)
+
+    @cached_property
+    def par_info(self):
+        """Returns the parameter information of the operations and measurements in the quantum script.
+
+        Returns:
+            list[dict[str, Operator or int]]: list of dictionaries with parameter information.
+
+        **Example**
+
+        >>> ops = [qml.StatePrep([0, 1], 0), qml.RX(0.432, 0), qml.CNOT((0,1))]
+        >>> qscript = QuantumScript(ops, [qml.expval(qml.Z(0))])
+        >>> qscript.par_info
+        [{'op': StatePrep(array([0, 1]), wires=[0]), 'op_idx': 0, 'p_idx': 0},
+        {'op': RX(0.432, wires=[0]), 'op_idx': 1, 'p_idx': 0}]
+
+        Note that the operations and measurements included in this list are only the ones that have parameters
         """
-        self._par_info = []
+        par_info = []
         for idx, op in enumerate(self.operations):
-            self._par_info.extend(
-                {"op": op, "op_idx": idx, "p_idx": i} for i, d in enumerate(op.data)
-            )
+            par_info.extend({"op": op, "op_idx": idx, "p_idx": i} for i, d in enumerate(op.data))
 
         n_ops = len(self.operations)
         for idx, m in enumerate(self.measurements):
             if m.obs is not None:
-                self._par_info.extend(
+                par_info.extend(
                     {"op": m.obs, "op_idx": idx + n_ops, "p_idx": i}
                     for i, d in enumerate(m.obs.data)
                 )
+        return par_info
+
+    @property
+    def obs_sharing_wires(self):
+        """Returns the subset of the observables that share wires with another observable,
+        i.e., that do not have their own unique set of wires.
+
+        Returns:
+            list[~.Observable]: list of observables with shared wires.
+
+        """
+        if self._obs_sharing_wires is None:
+            self._update_observables()
+        return self._obs_sharing_wires
+
+    @property
+    def obs_sharing_wires_id(self):
+        """Returns the indices subset of the observables that share wires with another observable,
+        i.e., that do not have their own unique set of wires.
+
+        Returns:
+            list[int]: list of indices from observables with shared wires.
+
+        """
+        if self._obs_sharing_wires_id is None:
+            self._update_observables()
+        return self._obs_sharing_wires_id
 
     def _update_observables(self):
         """Update information about observables, including the wires that are acted upon and
@@ -541,7 +570,7 @@ class QuantumScript:
         [0.432]
         """
         if self._trainable_params is None:
-            self._trainable_params = list(range(len(self._par_info)))
+            self._trainable_params = list(range(len(self.par_info)))
         return self._trainable_params
 
     @trainable_params.setter
@@ -554,7 +583,7 @@ class QuantumScript:
         if any(not isinstance(i, int) or i < 0 for i in param_indices):
             raise ValueError("Argument indices must be non-negative integers.")
 
-        num_params = len(self._par_info)
+        num_params = len(self.par_info)
         if any(i > num_params for i in param_indices):
             raise ValueError(f"Quantum Script only has {num_params} parameters.")
 
@@ -576,7 +605,7 @@ class QuantumScript:
         t_idx = self.trainable_params[idx]
 
         # get the info for the parameter
-        info = self._par_info[t_idx]
+        info = self.par_info[t_idx]
         return info["op"], info["op_idx"], info["p_idx"]
 
     def get_parameters(
@@ -619,7 +648,7 @@ class QuantumScript:
         if trainable_only:
             params = []
             for p_idx in self.trainable_params:
-                par_info = self._par_info[p_idx]
+                par_info = self.par_info[p_idx]
                 if operations_only and isinstance(self[par_info["op_idx"]], MeasurementProcess):
                     continue
 
@@ -629,7 +658,7 @@ class QuantumScript:
             return params
 
         # If trainable_only=False, return all parameters
-        # This is faster than the above and should be used when indexing `_par_info` is not needed
+        # This is faster than the above and should be used when indexing `par_info` is not needed
         params = [d for op in self.operations for d in op.data]
         if operations_only:
             return params
@@ -686,7 +715,7 @@ class QuantumScript:
         # determine the ops that need to be updated
         op_indices = {}
         for param_idx, idx in enumerate(sorted(indices)):
-            pinfo = self._par_info[idx]
+            pinfo = self.par_info[idx]
             op_idx, p_idx = pinfo["op_idx"], pinfo["p_idx"]
 
             if op_idx not in op_indices:
@@ -814,6 +843,7 @@ class QuantumScript:
         Returns:
             QuantumScript : a shallow copy of the quantum script
         """
+
         if copy_operations:
             # Perform a shallow copy of all operations in the operation and measurement
             # queues. The operations will continue to share data with the original script operations
@@ -824,7 +854,9 @@ class QuantumScript:
             # Perform a shallow copy of the operation and measurement queues. The
             # operations within the queues will be references to the original script operations;
             # changing the original operations will always alter the operations on the copied script.
+
             _ops = self.operations.copy()
+
             _measurements = self.measurements.copy()
 
         new_qscript = self.__class__(
@@ -835,14 +867,10 @@ class QuantumScript:
         )
         new_qscript._graph = None if copy_operations else self._graph
         new_qscript._specs = None
-        new_qscript.wires = copy.copy(self.wires)
-        new_qscript.num_wires = self.num_wires
-        new_qscript._update_par_info()
-        new_qscript._obs_sharing_wires = self._obs_sharing_wires
-        new_qscript._obs_sharing_wires_id = self._obs_sharing_wires_id
         new_qscript._batch_size = self._batch_size
         new_qscript._output_dim = self._output_dim
-
+        new_qscript._obs_sharing_wires = self._obs_sharing_wires
+        new_qscript._obs_sharing_wires_id = self._obs_sharing_wires_id
         return new_qscript
 
     def __copy__(self):
@@ -896,7 +924,6 @@ class QuantumScript:
         new_script = qml.tape.tape.expand_tape(
             self, depth=depth, stop_at=stop_at, expand_measurements=expand_measurements
         )
-        new_script._update()
         return new_script
 
     def adjoint(self):
@@ -940,7 +967,7 @@ class QuantumScript:
                 self.operations,
                 self.measurements,
                 self.wires,
-                self._par_info,
+                self.par_info,
                 self.trainable_params,
             )
 
diff --git a/pennylane/tape/tape.py b/pennylane/tape/tape.py
index 29c8802b5..9e7f3ff9c 100644
--- a/pennylane/tape/tape.py
+++ b/pennylane/tape/tape.py
@@ -99,14 +99,14 @@ def _validate_computational_basis_sampling(tape):
 
 def rotations_and_diagonal_measurements(tape):
     """Compute the rotations for overlapping observables, and return them along with the diagonalized observables."""
-    if not tape._obs_sharing_wires:
+    if not tape.obs_sharing_wires:
         return [], tape.measurements
 
     with (
         QueuingManager.stop_recording()
     ):  # stop recording operations to active context when computing qwc groupings
         try:
-            rotations, diag_obs = qml.pauli.diagonalize_qwc_pauli_words(tape._obs_sharing_wires)
+            rotations, diag_obs = qml.pauli.diagonalize_qwc_pauli_words(tape.obs_sharing_wires)
         except (TypeError, ValueError) as e:
             if any(isinstance(m, (ProbabilityMP, SampleMP, CountsMP)) for m in tape.measurements):
                 raise qml.QuantumFunctionError(
@@ -121,7 +121,7 @@ def rotations_and_diagonal_measurements(tape):
 
         measurements = copy.copy(tape.measurements)
 
-        for o, i in zip(diag_obs, tape._obs_sharing_wires_id):
+        for o, i in zip(diag_obs, tape.obs_sharing_wires_id):
             new_m = tape.measurements[i].__class__(obs=o)
             measurements[i] = new_m
 
@@ -216,7 +216,7 @@ def expand_tape(tape, depth=1, stop_at=None, expand_measurements=False):
             if isinstance(obj, Operator):
                 if obj.has_decomposition:
                     with QueuingManager.stop_recording():
-                        obj = QuantumScript(obj.decomposition(), _update=False)
+                        obj = QuantumScript(obj.decomposition())
                 else:
                     new_queue.append(obj)
                     continue
@@ -238,11 +238,9 @@ def expand_tape(tape, depth=1, stop_at=None, expand_measurements=False):
 
     # preserves inheritance structure
     # if tape is a QuantumTape, returned object will be a quantum tape
-    new_tape = tape.__class__(new_ops, new_measurements, shots=tape.shots, _update=False)
+    new_tape = tape.__class__(new_ops, new_measurements, shots=tape.shots)
 
     # Update circuit info
-    new_tape.wires = copy.copy(tape.wires)
-    new_tape.num_wires = tape.num_wires
     new_tape._batch_size = tape._batch_size
     new_tape._output_dim = tape._output_dim
     return new_tape
@@ -289,11 +287,9 @@ def expand_tape_state_prep(tape, skip_first=True):
 
     # preserves inheritance structure
     # if tape is a QuantumTape, returned object will be a quantum tape
-    new_tape = tape.__class__(new_ops, tape.measurements, shots=tape.shots, _update=False)
+    new_tape = tape.__class__(new_ops, tape.measurements, shots=tape.shots)
 
     # Update circuit info
-    new_tape.wires = copy.copy(tape.wires)
-    new_tape.num_wires = tape.num_wires
     new_tape._batch_size = tape._batch_size
     new_tape._output_dim = tape._output_dim
     return new_tape
@@ -314,9 +310,6 @@ class QuantumTape(QuantumScript, AnnotatedQueue):
         shots (None, int, Sequence[int], ~.Shots): Number and/or batches of shots for execution.
             Note that this property is still experimental and under development.
         trainable_params (None, Sequence[int]): the indices for which parameters are trainable
-        _update=True (bool): Whether or not to set various properties on initialization. Setting
-            ``_update=False`` reduces computations if the tape is only an intermediary step.
-
 
     **Example**
 
@@ -438,12 +431,9 @@ class QuantumTape(QuantumScript, AnnotatedQueue):
         measurements=None,
         shots=None,
         trainable_params=None,
-        _update=True,
     ):  # pylint: disable=too-many-arguments
         AnnotatedQueue.__init__(self)
-        QuantumScript.__init__(
-            self, ops, measurements, shots, trainable_params=trainable_params, _update=_update
-        )
+        QuantumScript.__init__(self, ops, measurements, shots, trainable_params=trainable_params)
 
     def __enter__(self):
         QuantumTape._lock.acquire()
@@ -478,7 +468,7 @@ class QuantumTape(QuantumScript, AnnotatedQueue):
             _ops (list[~.Operation]): Main tape operations
             _measurements (list[~.MeasurementProcess]): Tape measurements
 
-        Also calls `_update()` which sets many attributes.
+        Also calls `_update()` which invalidates the cached properties since ops and measurements are updated.
         """
         self._ops, self._measurements = process_queue(self)
         self._update()
diff --git a/pennylane/transforms/tape_expand.py b/pennylane/transforms/tape_expand.py
index 4b99f37e1..f3e9b0d26 100644
--- a/pennylane/transforms/tape_expand.py
+++ b/pennylane/transforms/tape_expand.py
@@ -171,7 +171,7 @@ def create_expand_trainable_multipar(tape, use_tape_argnum=False):
         return expand_trainable_multipar
 
     # pylint: disable=protected-access
-    trainable_par_info = [tape._par_info[i] for i in tape.trainable_params]
+    trainable_par_info = [tape.par_info[i] for i in tape.trainable_params]
     trainable_ops = [info["op"] for info in trainable_par_info]
 
     @qml.BooleanFn


Test patch:
diff --git a/tests/tape/test_qscript.py b/tests/tape/test_qscript.py
index 92743fc74..1d4f5cdff 100644
--- a/tests/tape/test_qscript.py
+++ b/tests/tape/test_qscript.py
@@ -30,12 +30,12 @@ class TestInitialization:
     """Test the non-update components of intialization."""
 
     def test_no_update_empty_initialization(self):
-        """Test initialization if nothing is provided and update does not occur."""
+        """Test initialization if nothing is provided"""
 
-        qs = QuantumScript(_update=False)
+        qs = QuantumScript()
         assert len(qs._ops) == 0
         assert len(qs._measurements) == 0
-        assert len(qs._par_info) == 0
+        assert len(qs.par_info) == 0
         assert qs._trainable_params is None
         assert qs.trainable_params == []
         assert qs._trainable_params == []
@@ -44,11 +44,19 @@ class TestInitialization:
         assert qs._shots.total_shots is None
         assert qs._batch_size is _UNSET_BATCH_SIZE
         assert qs.batch_size is None
+        assert qs._obs_sharing_wires is None
+        assert qs._obs_sharing_wires_id is None
         assert qs.wires == qml.wires.Wires([])
         assert qs.num_wires == 0
         assert qs.samples_computational_basis is False
-        assert len(qs._obs_sharing_wires) == 0
-        assert len(qs._obs_sharing_wires_id) == 0
+
+    def test_empty_sharing_wires(self):
+        """Test public sharing wires and id are empty lists if nothing is provided"""
+        qs = QuantumScript()
+        assert qs.obs_sharing_wires == []
+
+        qs = QuantumScript()
+        assert qs.obs_sharing_wires_id == []
 
     @pytest.mark.parametrize(
         "ops",
@@ -168,7 +176,7 @@ class TestUpdate:
         m = [qml.expval(qml.Hermitian(2 * np.eye(2), wires=0))]
         qs = QuantumScript(ops, m)
 
-        p_i = qs._par_info
+        p_i = qs.par_info
 
         assert p_i[0] == {"op": ops[0], "op_idx": 0, "p_idx": 0}
         assert p_i[1] == {"op": ops[1], "op_idx": 1, "p_idx": 0}
@@ -181,6 +189,22 @@ class TestUpdate:
 
         assert qs.trainable_params == list(range(8))
 
+    def test_cached_properties(self):
+        """Test that the @cached_property gets invalidated after update"""
+        ops = [
+            qml.RX(1.2, wires=0),
+            qml.Rot(2.3, 3.4, 5.6, wires=0),
+            qml.QubitUnitary(np.eye(2), wires=0),
+            qml.U2(-1, -2, wires=0),
+        ]
+        m = [qml.expval(qml.Hermitian(2 * np.eye(2), wires=0))]
+        qs = QuantumScript(ops, m)
+        qs._ops = []
+        qs._measurements = []
+        qs._update()
+        assert qs.wires == qml.wires.Wires([])
+        assert isinstance(qs.par_info, list) and len(qs.par_info) == 0
+
     # pylint: disable=unbalanced-tuple-unpacking
     def test_get_operation(self):
         """Tests the tape method get_operation."""
@@ -229,8 +253,24 @@ class TestUpdate:
             qml.PauliX(0) @ qml.PauliY(1),
         ]
         qs = QuantumScript(measurements=[qml.expval(o) for o in obs])
-        assert qs._obs_sharing_wires == [obs[1], obs[2], obs[4]]
-        assert qs._obs_sharing_wires_id == [1, 2, 4]
+        assert qs.obs_sharing_wires == [obs[1], obs[2], obs[4]]
+        assert qs.obs_sharing_wires_id == [1, 2, 4]
+
+    def test_update_no_sharing_wires(self):
+        """Tests the case where no observables share wires"""
+        obs = [
+            qml.PauliX("a"),
+            qml.PauliX(0),
+            qml.PauliY(1),
+            qml.PauliX("b"),
+            qml.PauliX(2) @ qml.PauliY(3),
+        ]
+        qs = QuantumScript(measurements=[qml.expval(o) for o in obs])
+        assert qs.obs_sharing_wires == []
+        assert qs.obs_sharing_wires_id == []
+        # Since the public attributes were accessed already, the private ones should be empty lists not None
+        assert qs._obs_sharing_wires == []
+        assert qs._obs_sharing_wires_id == []
 
     @pytest.mark.parametrize(
         "x, rot, exp_batch_size",
diff --git a/tests/tape/test_tape.py b/tests/tape/test_tape.py
index aa19e644b..c7805c396 100644
--- a/tests/tape/test_tape.py
+++ b/tests/tape/test_tape.py
@@ -720,7 +720,7 @@ class TestParameters:
 
         new_tape = tape.bind_new_parameters(new_params, [0, 1, 2, 3, 4])
 
-        for pinfo, pval in zip(new_tape._par_info, new_params):
+        for pinfo, pval in zip(new_tape.par_info, new_params):
             assert pinfo["op"].data[pinfo["p_idx"]] == pval
 
         assert new_tape.get_parameters() == new_params
@@ -735,7 +735,7 @@ class TestParameters:
         new_tape = tape.bind_new_parameters(new_params, tape.trainable_params)
 
         count = 0
-        for idx, pinfo in enumerate(new_tape._par_info):
+        for idx, pinfo in enumerate(new_tape.par_info):
             if idx in tape.trainable_params:
                 assert pinfo["op"].data[pinfo["p_idx"]] == new_params[count]
                 count += 1
@@ -778,7 +778,7 @@ class TestParameters:
         tape.trainable_params = [1, 3]
         new_tape = tape.bind_new_parameters(new_params, [0, 1, 2, 3, 4])
 
-        for pinfo, pval in zip(new_tape._par_info, new_params):
+        for pinfo, pval in zip(new_tape.par_info, new_params):
             assert pinfo["op"].data[pinfo["p_idx"]] == pval
 
         assert new_tape.get_parameters(trainable_only=False) == new_params
@@ -1207,8 +1207,8 @@ class TestExpand:
 
         assert len(tape.operations) == 1
         assert qml.equal(tape.operations[0], ops[0])
-        assert len(tape._obs_sharing_wires) == 2
-        assert all(qml.equal(obs, qml.PauliX(0)) for obs in tape._obs_sharing_wires)
+        assert len(tape.obs_sharing_wires) == 2
+        assert all(qml.equal(obs, qml.PauliX(0)) for obs in tape.obs_sharing_wires)
         assert qml.equal(tape.measurements[0], qml.expval(qml.PauliX(0)))
         assert qml.equal(tape.measurements[1], qml.expval(qml.PauliX(0)))
         assert tape.shots == qml.measurements.Shots(None)
@@ -1216,8 +1216,8 @@ class TestExpand:
         assert len(expanded.operations) == 2
         assert qml.equal(expanded.operations[0], ops[0])
         assert qml.equal(expanded.operations[1], qml.RY(-np.pi / 2, 0))  # new rotation
-        assert len(expanded._obs_sharing_wires) == 2
-        assert all(qml.equal(obs, qml.PauliZ(0)) for obs in expanded._obs_sharing_wires)
+        assert len(expanded.obs_sharing_wires) == 2
+        assert all(qml.equal(obs, qml.PauliZ(0)) for obs in expanded.obs_sharing_wires)
         assert qml.equal(expanded.measurements[0], qml.expval(qml.PauliZ(0)))
         assert qml.equal(expanded.measurements[1], qml.expval(qml.PauliZ(0)))
         assert expanded.shots is tape.shots