#| code-fold: true
import numpy as np
from gallop.structure import Structure
from gallop import tensor_prep
from gallop import zm_to_cart
from gallop import intensities
from gallop import chi2
= Structure(name="Verapamil", ignore_H_atoms=True)
mystructure "files/Verap.sdi", source="DASH")
mystructure.add_data("files/CURHOM_1.zmatrix", verbose=False)
mystructure.add_zmatrix("files/CURHOM_2.zmatrix", verbose=False)
mystructure.add_zmatrix(=False)
mystructure.get_total_degrees_of_freedom(verbose
# verapamil hydrochloride coordinates of global minimum from CIF.
= np.array([0.68394345,0.45950916,-0.114383094,0.2456631,-0.29722878,
g_external -0.10422839,0.3358067,0.7402369,0.7596887,-0.16798618])
= np.array([-1.2276717,2.9165819,-0.96692395,-1.2034712,3.2261908,
g_internal 2.1036072,-3.2097023,-3.0328763,1.5661795,-0.0071008434,
-0.1824131,-0.05715108,0.27950087])
= np.hstack([g_external,g_internal]) global_min
Introduction
In this post, we’ll be using the GALLOP Python API to to enable us to visualise \(\chi^2\) hypersurfaces. These hypersurfaces are functions of the molecular positions, orientations and conformations within the unit cell. As such, their dimensionality is typically high, meaning that we cant visualise them directly. However, we can visualise low-dimensional slices through them. We’ve seen an example of one of these slices an earlier post.
We’ll write a simple function that allows us to specify two or three vector directions, then sample \(\chi^2\) at points on a grid between these directions. We’ll also have a go at visualising the trajectories taken by particles initialised at each point on the grid as they are optimised with Adam, the default local optimiser in GALLOP.
For ease, we’ll continue using verapamil hydrochloride as our structure of interest. You can download the DASH fit files and Z-matrices I’ll be using here.
Choice of central point and slice directions
Central point
Usually when we are trying to solve the crystal structure of an unknown small molecule, we are most concerned with finding the global minimum of the \(\chi^2\) hypersurface. Therefore, we’ll start off investigating the region around the global minimum. Due to the repeating nature of a crystallographic lattice, and symmetries within unit cells, there are infinitely many points that correspond to the global minimum of the hypersurface - therefore, the coordinates below may not be the same as coordinates you obtain in your own solutions of verapamil hydrochloride.
When GALLOP is used to solve a crystal structure, the degrees of freedom corresponding to the solution are automatically added to the CIF as comments. For example, here is the header of a CIF for verapamil hydrochloride:
# Generated using pymatgen and GALLOP
# GALLOP External Coords = [0.68394345,0.45950916,-0.114383094,0.2456631,-0.29722878,-0.10422839,0.3358067,0.7402369,0.7596887,-0.16798618
# GALLOP Internal Coords = -1.2276717,2.9165819,-0.96692395,-1.2034712,3.2261908,2.1036072,-3.2097023,-3.0328763,1.5661795,-0.0071008434,-0.1824131,-0.05715108,0.27950087
# Profile chisqd = 16.741
data_Verap_0006_56.244_chisqd_252_refs_3.6_mins.cif
So as long as we’ve been able to solve the crystal structure of interest with GALLOP, we’ll have easy access to the coordinates of a global minimum point.
We can also plot around a random point on the hypersurface to give a comparison.
Slice directions
In this post, we’ll look at two options for plotting. The first and most obvious will be to plot along some of the degrees of freedom in the structure to see how they interact.
The other option will be to choose random unit vectors and slice the surface along them to get a feel for how all of the degrees of freedom interact. In high dimensional spaces, randomly generated pairs of vectors will be approximately orthogonal so it should be possible to get reasonable levels of independence between the axes in our plots.
Data loading and functions
We need to import some libraries, then load our data and Z-matrices.
We also need a function to produce our vectors, grid and points at which to sample \(\chi^2\).
Code
def get_vectors_and_angle(n_vectors, structure):
# Generate random unit vectors and calculate the angles between them
= np.random.uniform(-1,1,(n_vectors, structure.total_degrees_of_freedom))
vectors /= np.linalg.norm(vectors)
vectors if n_vectors == 2:
= np.array(np.rad2deg(np.arccos(np.dot(vectors[0], vectors[1]))))
angle else:
= np.array([
angle 0], vectors[1]))),
np.rad2deg(np.arccos(np.dot(vectors[0], vectors[2]))),
np.rad2deg(np.arccos(np.dot(vectors[1], vectors[2])))
np.rad2deg(np.arccos(np.dot(vectors[
])return vectors, angle
def get_points(structure, global_minimum, dims="random", n_vectors=2, n_points=50):
if dims != "random":
assert len(dims) in [2,3], "Number of dims must be 2 or 3"
if dims == "random":
# Randomly generate a set of n_vectors, and continue randomly generating
# them until the angles between them are all in the range 88-92 degrees.
= get_vectors_and_angle(n_vectors, structure)
vectors, angle
while np.abs(90 - angle).max() > 2:
= get_vectors_and_angle(n_vectors, structure)
vectors, angle
= [np.linspace(-1,1,n_points) for i in range(n_vectors)]
gridpoints print("Angle:",angle)
else:
= np.zeros((len(dims), structure.total_degrees_of_freedom),
vectors =np.float32)
dtype
= []
gridpoints for i, d in enumerate(dims):
*= 0
global_minimum[d] = 1
vectors[i,d] # If we are looking at the unit cell dimensions, then we want to
# ensure that the range of data we plot captures the range the parameter
# can take.
if d < structure.total_position_degrees_of_freedom:
0,1,n_points))
gridpoints.append(np.linspace(elif d < structure.total_external_degrees_of_freedom:
-1,1,n_points))
gridpoints.append(np.linspace(else:
-np.pi,np.pi,n_points))
gridpoints.append(np.linspace(
if len(gridpoints) == 2:
= np.meshgrid(gridpoints[0], gridpoints[1])
xx, yy = global_minimum + xx.ravel().reshape(-1,1)*vectors[0] + yy.ravel().reshape(-1,1)*vectors[1]
points = [xx, yy]
grid else:
= np.meshgrid(gridpoints[0], gridpoints[1], gridpoints[2])
xx, yy, zz = global_minimum + xx.ravel().reshape(-1,1)*vectors[0] + yy.ravel().reshape(-1,1)*vectors[1] + zz.ravel().reshape(-1,1)*vectors[2]
points = [xx, yy, zz]
grid
= points[:,:structure.total_external_degrees_of_freedom]
external = points[:,structure.total_external_degrees_of_freedom:]
internal
return external, internal, grid, gridpoints
Next we’ll need a function to calculate \(\chi^2\) at each point on our grid
Code
def get_chi_squared(mystructure, external, internal):
= tensor_prep.get_all_required_tensors(mystructure,
tensors =external, internal=internal, requires_grad=False)
external= zm_to_cart.get_asymmetric_coords(**tensors["zm"])
asymmetric_frac_coords = intensities.calculate_intensities(
calculated_intensities **tensors["int_tensors"])
asymmetric_frac_coords, = chi2.calc_int_chisqd(calculated_intensities, **tensors["chisqd_tensors"])
chisquared
= chisquared.detach().cpu().numpy()
chisquared return chisquared
And lastly, a function to produce our plots
Code
from IPython.display import HTML
import plotly.graph_objects as go
from plotly.offline import init_notebook_mode
'''<script src="/static/components/requirejs/require.js"></script>'''))
display(HTML(=False)
init_notebook_mode(connected
def get_plot(chisquared, grid, gridpoints, dim1="x", dim2="y", percentile=50):
if len(grid) == 2:
# create figure
= go.Figure()
fig
# Add surface trace
fig.add_trace(=gridpoints[0],
go.Contour(x=gridpoints[1],
y=chisquared.reshape(grid[0].shape),
z="Inferno")
colorscale
)
# Update plot sizing
fig.update_layout(=700,
width=600,
height=False,
autosize=dict(t=0, b=0, l=0, r=0),
margin="plotly_white",
template
)
# Update 3D scene options
fig.update_scenes(=dict(x=1, y=1, z=0.8),
aspectratio="manual"
aspectmode
)
# Add dropdown
fig.update_layout(=[
updatemenusdict(
=list([
buttonsdict(
=["type", "contour"],
args="Contour",
label="restyle"
method
),dict(
=["type", "surface"],
args="3D Surface",
label="restyle"
method
)
]),="down",
direction={"r": 10, "t": 10},
pad=True,
showactive=0.1,
x="left",
xanchor=1.1,
y="top"
yanchor
),
],=dict(
xaxis=dim1,
title
),=dict(
yaxis=dim2,
title
),=dict(x=-1, y=-3, z=0.9),
scene_camera_eye
)else:
= go.Figure(data=go.Volume(
fig =grid[0].flatten(),
x=grid[1].flatten(),
y=grid[2].flatten(),
z=chisquared,
value=np.percentile(chisquared, 0),
isomin=np.percentile(chisquared, percentile),
isomax=0.1, # needs to be small to see through all surfaces
opacity=10, # needs to be a large number for good volume rendering
surface_count=True,
reversescale
))
fig.update_layout(=700,
width=600,
height=False,
autosize=dict(t=0, b=0, l=0, r=0),
margin="plotly_white",
template
)
return fig
Plotting degrees of freedom
2D slices
Now that all of our functions are ready, let’s first have a go at plotting some 2D slices through the surface, in the region of the global minimum.
In an earlier post, we looked at the verapamil position along \(a\) and \(c\). In this post, let’s take a look at some of the quaternion components for the verapamil: q1 and q2.
Code
= get_points(mystructure, np.copy(global_min), dims=(6,7), n_points=100)
external, internal, grid, gridpoints = get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, dim1="Verapamil q1", dim2="Verapamil q2")
fig
fig.show()
Now let’s take a look at two of the torsion angles in verapamil, how about torsion 1 and 2, which correspond to the following torsions (using the CURHOM atom-labels): 1. N1 C9 C10 C11 2. C12 C11 C10 C9
Code
= get_points(mystructure, np.copy(global_min), dims=(10,11), n_points=100)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, dim1="N1 C9 C10 C11", dim2="C12 C11 C10 C9")
fig fig.show()
In the examples above, the surfaces that result are relatively smooth and have relatively few local minima.
As a comparison point, let’s regenerate the plots above, but this time instead of visualising around the global minimum, let’s use random coordinates instead.
Code
314159)
np.random.seed(= np.random.uniform(0,1,mystructure.total_position_degrees_of_freedom)
random_pos = np.random.uniform(-1,1,mystructure.total_rotation_degrees_of_freedom)
random_rot /= np.linalg.norm(random_rot)
random_rot = np.random.uniform(-np.pi,np.pi,mystructure.total_internal_degrees_of_freedom)
random_tors
= np.hstack([random_pos, random_rot, random_tors])
random_point print("Random coordinates:", random_point)
= []
figs for dims in [[6,7, "Verapamil q1", "Verapamil q2"],
10,11, "N1 C9 C10 C11", "C12 C11 C10 C9"]]:
[= get_points(mystructure, np.copy(random_point), dims=dims[:2], n_points=100)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
=dims[2], dim2=dims[3]))
figs.append(get_plot(chisquared, grid, gridpoints, dim1
for fig in figs:
fig.show()
Random coordinates: [ 0.81792331 0.5510463 0.41977535 0.09869185 0.81102075 0.9673564
-0.77218306 0.58012779 0.20160524 0.16291222 -0.04343231 -0.1460866
-1.48303189 -1.19629038 2.56325063 -1.97929866 -1.95884954 -2.58621942
2.3512614 0.89477677 2.21686588 0.20772398 2.38385342]
Things aren’t looking quite so smooth as they were before!
This may partly be due to the scaling effect of no longer having the (very deep) global minimum present. To test that, let’s replot our earlier torsion angle plot, but limit the minimum \(\chi^2\) to the 5th percentile value. With this, we get about the same range of \(\chi^2\) values as in the torsion angle plot around the random point. As we can see, the surface around the global minimum still looks more smooth, albeit with a few more shallow local minima now visible.
Code
= get_points(mystructure, np.copy(global_min), dims=(10,11), n_points=100)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared < np.percentile(chisquared, 5)] = np.percentile(chisquared, 5)
chisquared[chisquared
= get_plot(chisquared, grid, gridpoints, dim1="N1 C9 C10 C11", dim2="C12 C11 C10 C9")
fig fig.show()
3D slices
Now let’s turn our attention to 3D slices through the surface. We’ll use a 3D volume plot from the plotly library to visualise three different dimensions at the same time. Due to the exponential increase in number of points we’ll have to evaluate, the grid resolution will be coming down a bit! To make things a bit easier to see, I’ve reversed the colourscale used, so now orange and yellow represent regions of low \(\chi^2\).
Let’s see the position of the chloride ion within the unit cell, with all other degrees of freedom fixed at the global minimum. For clarity, we’ll only visualise the isosurfaces below the fiftieth percentile of the \(\chi^2\) values.
Code
= get_points(mystructure, np.copy(global_min), dims=(3,4,5), n_points=25)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, percentile=50)
fig fig.show()
Let’s do the same thing for another selection of degrees of freedom, in this case, verapamil along \(a\), the third quaternion component and the 4th torsion angle, which corresponds to C13 C12 C11 C10.
Code
= get_points(mystructure, np.copy(global_min), dims=(3,8,13), n_points=25)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, percentile=50)
fig fig.show()
It’s a bit harder to tell what’s going on in these plots - we have to infer the gradient from the coloured isosurfaces. However, to my eye, they still look relatively smooth. Let’s take the same random point as before and plot the same 3D-slices and see if the slices look less smooth.
Code
= []
figs for dims in [[3,4,5],
3,8,13]]:
[= get_points(mystructure, np.copy(random_point), dims=dims[:3], n_points=25)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
=50))
figs.append(get_plot(chisquared, grid, gridpoints, percentile
for fig in figs:
fig.show()
Hard to tell! But to me they look more complex.
Plotting random directions
So far, our plots have looked at slicing the hypersurface along known directions such as fractional coordinates or torsion angles. Now we’ll generate some random (almost) orthogonal unit vectors and slice the surface in those directions.
Code
42)
np.random.seed(= get_points(mystructure, np.copy(global_min), n_vectors=2, n_points=100)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, dim1="Random 1", dim2="Random 2")
fig fig.show()
Angle: 91.52349584729872
Code
43)
np.random.seed(= get_points(mystructure, np.copy(global_min), n_vectors=3, n_points=25)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, percentile=50)
fig fig.show()
Angle: [89.56994448 91.07328889 91.30510482]
Let’s repeat the above plots, this time centred around the random point we generated previously.
Code
42)
np.random.seed(= get_points(mystructure, np.copy(random_point), n_vectors=2, n_points=100)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, dim1="Random 1", dim2="Random 2")
fig fig.show()
Angle: 91.52349584729872
Code
43)
np.random.seed(= get_points(mystructure, np.copy(random_point), n_vectors=3, n_points=25)
external, internal, grid, gridpoints
= get_chi_squared(mystructure, external, internal)
chisquared
= get_plot(chisquared, grid, gridpoints, percentile=50)
fig fig.show()
Angle: [89.56994448 91.07328889 91.30510482]
Trajectories
Now we’ll have a go at visualising the paths taken by the particles as they are optimised. We will limit ourselves to 2D slices for ease.
GALLOP actually includes a convenience function for recording the trajectories during the local optimisation steps - just add:
"save_trajectories"] = True minimiser_settings[
to the minimiser settings before calling the minimise function, and the result dictionary will have an entry under the key trajectories
.
However, we’ll need some slightly different characteristics for our plotting: we need a minimisation function that allows us to fix all of the degrees of freedom other than the ones we are plotting or allow all of the degrees of freedom to refine as usual.
Minimisation function
Code
import tqdm
import torch
def minimise(structure, external, internal, dims, fix=True, n_iterations=100, lr=0.01):
= []
trajectories
if fix:
= tensor_prep.get_all_required_tensors(structure,
tensors =external, internal=internal,
external=False)
requires_grad= torch.from_numpy(np.hstack([external, internal])[:,dims]
grid type(torch.float32).cuda()
).= True
grid.requires_grad = torch.cat([tensors["zm"]["external"],
alldof "zm"]["internal"]], dim=-1)
tensors[= torch.optim.Adam([grid], lr=lr, betas=[0.9,0.9])
optimizer else:
= tensor_prep.get_all_required_tensors(structure,
tensors =external, internal=internal,
external=True)
requires_grad= torch.optim.Adam([tensors["zm"]["external"],
optimizer "zm"]["internal"]],
tensors[=lr, betas=[0.9,0.9])
lr= range(n_iterations)
local_iters for j in local_iters:
# Zero the gradients before each iteration otherwise they accumulate
optimizer.zero_grad()if fix:
= torch.cat([alldof[:,:dims[0]],
grid_dofs 0].unsqueeze(1),
grid[:,0]+1:dims[1]],
alldof[:,dims[1].unsqueeze(1),
grid[:,1]+1:]
alldof[:,dims[=-1)
], dim"zm"]["external"] = grid_dofs[:,:structure.total_external_degrees_of_freedom]
tensors["zm"]["internal"] = grid_dofs[:,structure.total_external_degrees_of_freedom:]
tensors[= zm_to_cart.get_asymmetric_coords(**tensors["zm"])
asymmetric_frac_coords = intensities.calculate_intensities(
calculated_intensities **tensors["int_tensors"])
asymmetric_frac_coords, = chi2.calc_int_chisqd(calculated_intensities, **tensors["chisqd_tensors"])
chisquared
"zm"]["external"].detach().cpu().numpy(),
trajectories.append([tensors["zm"]["internal"].detach().cpu().numpy(),
tensors[
chisquared.detach().cpu().numpy()])# For the last iteration, don't step the optimiser, otherwise the chi2
# value won't correspond to the DoFs
if j != n_iterations - 1:
= torch.sum(chisquared)
L
L.backward()
optimizer.step()
return trajectories
We’ll also need a function to plot the resultant trajectories. It’ll be useful to compare the slice of the surface before optimisation to the starting points that reached a solution by the end.
Code
import matplotlib.pyplot as plt
def plot_trajectories(mystructure, dims, global_minimum, lr=0.01, fix=True, n_iterations=100, n_points=50):
= get_points(mystructure, np.copy(global_minimum),
external, internal, grid, gridpoints =dims, n_points=n_points)
dims
= minimise(mystructure, external, internal, dims, fix=fix,
trajectories =n_iterations, lr=lr)
n_iterations
= []
coords = []
chi2s for t in trajectories:
2])[:,dims])
coords.append(np.hstack(t[:2])
chi2s.append(t[= np.dstack(coords)
coords
= plt.subplots(1,3,figsize=(44,12))
fig, ax 0].set_title("$\\chi^2$ surface")
ax[0].contour(gridpoints[0], gridpoints[0], chi2s[0].reshape(grid[0].shape),
ax[="viridis", levels=20)
cmap
= chi2s[-1] - chi2s[-1].min()
chi2temp =plt.cm.viridis(chi2temp/chi2s[-1].max())
col
col
1].set_title("Particle trajectories")
ax[1].scatter(coords[:,0,0], coords[:,1,0], color=col[0], s=10, alpha=.125)
ax[
for i in range(coords.shape[0]):
1].plot(coords[i,0,:], coords[i,1,:], color=col[i], alpha=0.125)
ax[
= np.around(100*(chi2s[-1]<60).sum()/chi2s[-1].shape[0], 1)
percent_solved 2].set_title(f"Final $\\chi^2$ - {percent_solved} % solved")
ax[2].contourf(gridpoints[0], gridpoints[0], chi2s[-1].reshape(grid[0].shape),
ax[="viridis", levels=20)
cmap
plt.show()return coords, chi2s, grid, gridpoints
Let’s now visualise the trajectories taken if we slice along the verapamil position along \(a\) and \(b\), as well as torsion 1 and torsion 2. The upper plot in each case shows the trajectories if all of the degrees of freedom other than those being plotted are fixed. The lower plot shows the trajectories taken if everything is allowed to refine (as would be normal in GALLOP).
The plots might appear a little small on the blog - if you want larger views, right click on the image and open them in a new tab.
Code
=(3,4)
dimsprint(dims,"others fixed")
= plot_trajectories(mystructure, dims, np.copy(global_min), lr=0.01, fix=True, n_iterations=100, n_points=50)
fixed_trajectories_34
print(dims, "others free")
= plot_trajectories(mystructure, dims, np.copy(global_min), lr=0.01, fix=False, n_iterations=100, n_points=50)
free_trajectories_34
=(10,11)
dimsprint(dims,"others fixed")
= plot_trajectories(mystructure, dims, np.copy(global_min), lr=0.01, fix=True, n_iterations=100, n_points=50)
fixed_trajectories_1011
print(dims, "others free")
= plot_trajectories(mystructure, dims, np.copy(global_min), lr=0.01, fix=False, n_iterations=100, n_points=50) free_trajectories_1011
(3, 4) others fixed
(3, 4) others free
(10, 11) others fixed
(10, 11) others free
As we can see, as we allow the other degrees of freedom to refine, we end up with different behaviour. In the example of the fractional coordinates (dims = 3,4) can now see two points within the unit cell that constitute a global minimum after refinement - this is because in the second plot, the chloride ion is free to move to accommodate the different positions of the verapamil molecule.
Another point of interest is the effects of momentum in the Adam optimiser are clearly visible - the trajectories in some cases backtrack after moving in a particular direction for a while. This is because momentum has carried them “uphill”. This property can allow the local optimisation algorithm in GALLOP to escape shallow local minima, as well as pass quickly through flat regions of the hypersurface.
Lastly, let’s animate the trajectories!
Code
from matplotlib import animation
def generate_animation(coords, chi2s, grid, gridpoints, name="fig.gif", type="frac"):
# First set up the figure, the axis, and the plot element we want to animate
= plt.figure(figsize=(10,10))
fig = plt.axes(xlim=(gridpoints[0].min(), gridpoints[0].max()),
ax =(gridpoints[1].min(), gridpoints[1].max()))
ylim
0], gridpoints[1], chi2s[0].reshape(grid[0].shape), cmap="viridis", levels=20)
ax.contour(gridpoints[
= ax.scatter(coords.T[0, 0, :], coords.T[0, 1, :], c=chi2s[-1],
scatter =5, alpha=0.25)
s
# animation function. This is called sequentially
def animate(i, coords, chi2s):
#scatter.set_offsets(coords.T[i, :, :].T)
if type == "frac":
% 1)
scatter.set_offsets(coords.T[i, :, :].T elif type == "torsion":
scatter.set_offsets(np.arctan2(np.sin(coords.T[i, :, :].T), np.cos(coords.T[i, :, :].T)))#scatter.set_array(chi2s[i])
-1])
scatter.set_array(chi2s[return scatter,
= animation.FuncAnimation(fig, animate, frames=range(coords.T.shape[0]), blit=True,
ani =(coords, chi2s), interval=100,)
fargs
ani.save(name)#plt.show()
return None
*fixed_trajectories_34, name="images/animation_34.gif", type="frac")
generate_animation(*fixed_trajectories_1011, name="images/animation_1011.gif", type="torsion") generate_animation(
Verapamil along a and b
Verapamil torsion 1 and 2
Things seem to move much more slowly in the torsion angle example - I suspect this is because the gradient on the “flat” region is low enough that it takes a while for the particles to pick up speed!
Conclusions
Using GALLOP and relatively little boilerplate code, it’s possible to easily plot slices through \(\chi^2\) hypersurfaces, and visialise trajectories taken by the local optimiser.
In a future post, we’ll look at trying to extend our visualisation to full GALLOP solutions including the particle swarm optimmisation steps. We’ll make use of dimensionality reduction techniques such as Principal Component Analysis and Uniform Manifold Approximation to allow us to visualise all of the degrees of freedom at once!