Part 1: Basic Linear Transformations¶
1. Experiment Design¶
The code shows how the unit circle and the unit square change under a linear transformation. Let us go through its components step by step:
2. Visual Elements¶
3. Control Design¶
4. Figure Display¶
The final visualization contains:
the original figure (circle or square) and the transformed figure
a 1:1 aspect ratio for the axes
the title “Figure & Linear Mapping”
# Colab: enable the custom widget manager so that FigureWidget and the sliders are interactive; skipped elsewhere
try:
from google.colab import output
output.enable_custom_widget_manager()
except ImportError:
pass
# Import the libraries
import plotly.graph_objects as go # for drawing interactive figures
import plotly.figure_factory as ff # for creating various kinds of figures, including streamline plots and vector fields
import numpy as np # numerical library, in particular for arrays and linear algebra
from ipywidgets import widgets # for creating interactive widgets
# Define a transformation function that applies a linear transformation to 2D coordinates
def transformation(x, y, A=[[3,-2],[2,2]]):
# Convert x, y to NumPy arrays so that matrix operations can be applied
x = np.array(x)
y = np.array(y)
A = np.array(A) # transformation matrix
# Transform the coordinates by matrix multiplication and return the new coordinates
# by default: 3 * x -2 *y,2 * x +2* y
return np.dot(A, (np.vstack((x, y))))
# Create the circle data
def create_circle_data():
theta = np.linspace(0, 2*np.pi, 200) # linearly spaced values from 0 to 2pi, representing the angle
r = np.ones_like(theta) # all radii equal 1, giving the unit circle
# Generate the x, y coordinates of the circle from polar coordinates
x = r * np.cos(theta)
y = r * np.sin(theta)
return x, y, theta
# Create the square data
def create_square_data():
# Create the boundary points of the unit square
n_points = 50 # number of points per side
# Bottom side: from (-1,-1) to (1,-1)
bottom_x = np.linspace(-1, 1, n_points)
bottom_y = np.full(n_points, -1)
# Right side: from (1,-1) to (1,1)
right_x = np.full(n_points, 1)
right_y = np.linspace(-1, 1, n_points)
# Top side: from (1,1) to (-1,1)
top_x = np.linspace(1, -1, n_points)
top_y = np.full(n_points, 1)
# Left side: from (-1,1) to (-1,-1)
left_x = np.full(n_points, -1)
left_y = np.linspace(1, -1, n_points)
# Combine all sides
x = np.concatenate([bottom_x, right_x, top_x, left_x])
y = np.concatenate([bottom_y, right_y, top_y, left_y])
# Create the color mapping (playing the role of the angle for the circle)
color_values = np.linspace(0, 2*np.pi, len(x))
return x, y, color_values
# Get the data for the given shape type
def get_shape_data(shape_type):
if shape_type == 'circle':
return create_circle_data()
else: # square
return create_square_data()
# Initialize the shape selector
shape_selector = widgets.ToggleButtons(
options=[('Circle', 'circle'), ('Square', 'square')],
value='circle',
description='Shape:',
disabled=False,
button_style='',
tooltips=['Select the unit circle', 'Select the unit square'],
)
# Initialize the figure data
x1, y1, color_values = get_shape_data('circle')
# Define the plot of the first figure, using a color gradient
trace = dict(
type='scatter', # scatter plot
x=x1, # x coordinates
y=y1, # y coordinates
mode='markers', # draw point markers
marker=dict(color=color_values, colorscale='Aggrnyl', size=4) # use a gradient color mapping; marker size 4
)
# Compute the coordinates of the second figure by the linear transformation
x2, y2 = transformation(x1, y1)
# Define the plot of the second figure, after the linear transformation
trace2 = dict(
type='scatter', # scatter plot
x=x2, # x coordinates
y=y2, # y coordinates
mode='markers', # draw point markers
marker=dict(color=color_values, colorscale='Aggrnyl', size=4) # use the same color gradient
)
# Define the starting points and directions of the vector field
quiverx = [10*np.cos(0), 10*np.cos(np.pi/2)]
quivery = [10*np.sin(0), 10*np.sin(np.pi/2)]
# Apply the linear transformation to these vectors
quiverx2, quivery2 = transformation(quiverx, quivery)
# Create a figure object containing the vector field
qfig = ff.create_quiver([0], [0], [quiverx[0]], [quivery[0]], line_color='blue') # blue vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx2[0]], [quivery2[0]], line_color='blue').data[0]) # transformed blue vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx[1]], [quivery[1]], line_color='red').data[0]) # red vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx2[1]], [quivery2[1]], line_color='red').data[0]) # transformed red vector
# Create the combined figure, merging the scatter plots above with the vector field
fig = go.FigureWidget(data=[trace, trace2, qfig.data[0], qfig.data[1], qfig.data[2], qfig.data[3]])
# Set the dashed line style
fig.data[3].line['dash'] = 'dash' # make line 3 (fig.data[3]) dashed
fig.data[5].line['dash'] = 'dash' # make line 5 (fig.data[5]) dashed
# Update the y-axis scale so that it is 1:1 with the x-axis
fig.update_yaxes(
scaleanchor="x", # the y-axis scale is tied to the x-axis
scaleratio=1, # ensure equal scales
)
# Update the layout: add the title and other settings
fig.update_layout(
title="Figure & Linear Mapping", # set the figure title
width=800, # set the figure width
height=600, # set the figure height
showlegend=False # hide the legend
)
# Define an integer slider for adjusting the rotation angle (in degrees)
degree = widgets.IntSlider(
value=0.0, # initial value 0 degrees
min=0.0, # minimum 0 degrees
max=360.0, # maximum 360 degrees
step=5.0, # step size 5 degrees
description='Angle:', # slider label
continuous_update=False # update only when the slider is released
)
# Put the controls in a horizontal box container for display
container = widgets.HBox(children=[shape_selector, degree])
# Define a validation function ensuring that degree lies between 0 and 360 degrees
def validate():
if (degree.value <= 360) & (degree.value >= 0):
return True
else:
return False
# Define the response function, which updates the figure when a slider value or the shape selection changes
def response(change):
# update only if the validation passes
if validate():
# convert the angle to radians for the computations below
newDegree = 2 * np.pi * degree.value / 360
# get the base data for the selected shape
x1_base, y1_base, color_values = get_shape_data(shape_selector.value)
# apply the rotation
if shape_selector.value == 'circle':
# rotate the circle
theta = np.linspace(0, 2*np.pi, 200) + newDegree
r = np.ones_like(theta)
x1 = r * np.cos(theta)
y1 = r * np.sin(theta)
else:
# rotate the square
cos_deg = np.cos(newDegree)
sin_deg = np.sin(newDegree)
x1 = x1_base * cos_deg - y1_base * sin_deg
y1 = x1_base * sin_deg + y1_base * cos_deg
# Get the current transformation parameters: build the transformation matrix A from the values of the 2D slider array
A = np.array([[slider.value for slider in row] for row in sliders_2d])
# apply the linear transformation to the original figure to get the mapped coordinates
x2, y2 = transformation(x1, y1, A)
# define the original vectors of the vector field (rotated)
quiverx = [10 * np.cos(0 + newDegree), 10 * np.cos(newDegree + np.pi/2)]
quivery = [10 * np.sin(0 + newDegree), 10 * np.sin(newDegree + np.pi/2)]
# apply the linear transformation to the vectors to get the transformed vectors
quiverx2, quivery2 = transformation(quiverx, quivery, A)
# create the vector field figure object:
# first vector: blue, the original vector
qfig = ff.create_quiver([0], [0], [quiverx[0]], [quivery[0]], line_color='blue')
# add the transformed blue vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx2[0]], [quivery2[0]], line_color='blue').data[0])
# add the original red vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx[1]], [quivery[1]], line_color='red').data[0])
# add the transformed red vector
qfig.add_trace(ff.create_quiver([0], [0], [quiverx2[1]], [quivery2[1]], line_color='red').data[0])
# update the figure data in a batch for performance
with fig.batch_update():
# update the coordinates of the original figure
fig.data[0].x = x1
fig.data[0].y = y1
fig.data[0].marker.color = color_values # update the colors
# update the coordinates of the linearly mapped figure
fig.data[1].x = x2
fig.data[1].y = y2
fig.data[1].marker.color = color_values # update the colors
# update the coordinates of each arrow of the vector field
for i in range(2, 6):
fig.data[i].x = qfig.data[i-2].x
fig.data[i].y = qfig.data[i-2].y
# When the value of the angle slider changes, call response to update the figure
degree.observe(response, names="value")
# When the value of the shape selector changes, call response to update the figure
shape_selector.observe(response, names="value")
# Create sliders arranged as a 2D array for adjusting the entries of the transformation matrix A
A = np.array([[3, -2], [2, 2]]) # define the initial transformation matrix
sliders_2d = [
[widgets.IntSlider(
value=A[i][j], # initial value taken from the corresponding entry of A
min=-10, # minimum entry value -10
max=10, # maximum entry value 10
description=f'A [{i}][{j}]' # the slider label shows the position in the matrix
) for j in range(2)]
for i in range(2)
]
# Add an observer to each matrix slider: when its value changes, the response function updates the figure
for row in sliders_2d:
for slider in row:
slider.observe(response, names='value')
# Flatten the 2D list of sliders into a 1D list for arranging them in a grid
grid_items = [slider for row in sliders_2d for slider in row]
# Create a GridBox layout that arranges the matrix sliders in a grid
grid_box = widgets.GridBox(
grid_items,
layout=widgets.Layout(
grid_template_columns='repeat(2, 300px)', # two columns, each 300 pixels wide
grid_gap='10px' # 10-pixel gap between sliders
)
)
# Combine the control container, the matrix slider grid, and the figure in a vertical layout for display
widgets.VBox([container, grid_box, fig])Loading...
Part 2: Observation and Reflection¶
Answer the following four questions, explaining your conclusions with what you actually did in the interactive demonstration.