File size: 8,129 Bytes
88e3e8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Full-dimensional analogy arithmetic and shared linear projections."""
import numpy as np
import plotly.graph_objects as go
from plotly.colors import qualitative


def cosine(a, b):
    a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
    denominator = np.linalg.norm(a) * np.linalg.norm(b)
    return float(np.clip(np.dot(a, b) / denominator, -1, 1)) if denominator > 0 and np.isfinite(denominator) else None


def analyse_pairs(pairs, target, expected=None):
    """Each pair is (A, B); apply their mean offset to C, optionally compare D."""
    if not pairs:
        raise ValueError('Add at least one example pair.')
    arrays = [np.asarray(v, dtype=float) for pair in pairs for v in pair] + [np.asarray(target, dtype=float)]
    if expected is not None:
        arrays.append(np.asarray(expected, dtype=float))
    if any(v.ndim != 1 or v.shape != arrays[0].shape or not np.isfinite(v).all() for v in arrays):
        raise ValueError('All inputs must be finite vectors with matching dimensions.')
    offsets = np.array([np.asarray(b, dtype=float) - np.asarray(a, dtype=float) for a, b in pairs])
    offset = offsets.mean(axis=0)
    prediction = np.asarray(target, dtype=float) + offset
    agreement = [cosine(v, offset) for v in offsets]
    result = {'offset': offset, 'prediction': prediction, 'offset_length': float(np.linalg.norm(offset)),
              'example_agreement': float(np.mean([x for x in agreement if x is not None])) if any(x is not None for x in agreement) else None}
    if expected is not None:
        observed = np.asarray(expected, dtype=float) - np.asarray(target, dtype=float)
        alignment = cosine(offset, observed)
        result.update(direction_cosine=alignment,
                      angle_degrees=float(np.degrees(np.arccos(alignment))) if alignment is not None else None,
                      observed_length=float(np.linalg.norm(observed)),
                      length_ratio=float(np.linalg.norm(observed) / np.linalg.norm(offset)) if np.linalg.norm(offset) > 0 else None,
                      prediction_error=float(np.linalg.norm(prediction - expected)))
    return result


def fit_projection(points, dimensions):
    points = np.asarray(points, dtype=float)
    if points.ndim != 2 or not len(points) or not np.isfinite(points).all() or dimensions not in (2, 3):
        raise ValueError('Projection needs finite points and two or three components.')
    mean = points.mean(axis=0)
    centered = points - mean
    _, singular, vt = np.linalg.svd(centered, full_matrices=False)
    basis = np.zeros((dimensions, points.shape[1]))
    tolerance = max(centered.shape) * np.finfo(float).eps * (singular[0] if len(singular) else 0.)
    rank = int(np.count_nonzero(singular > tolerance))
    count = min(dimensions, rank)
    basis[:count] = vt[:count]
    total = float(np.dot(singular, singular))
    retained = float(np.dot(singular[:count], singular[:count]) / total) if total > 0 else 1.
    return mean, basis, retained


def relationship_figure(analysis, dimensions, extra_vectors=None, extra_labels=None):
    """Fit one PCA to the example endpoints; project predictions/candidates with it."""
    points, point_labels, point_codes, point_keys = [], [], [], []
    for index, pair in enumerate(analysis['pairs'], 1):
        for letter in ('a', 'b'):
            points.append(pair[letter]['values'])
            point_labels.append(pair[letter]['label'])
            point_codes.append(letter.upper() + (str(index) if len(analysis['pairs']) > 1 else ''))
            point_keys.append(pair[letter]['key'])
    points.append(analysis['target']['values'])
    point_labels.append(analysis['target']['label'])
    point_codes.append('C')
    point_keys.append(analysis['target']['key'])
    if analysis['expected'] is not None:
        points.append(analysis['expected']['values'])
        point_labels.append(analysis['expected']['label'])
        point_codes.append('D')
        point_keys.append(analysis['expected']['key'])
    mean, basis, retained = fit_projection(points, dimensions)
    project = lambda values: (np.asarray(values) - mean) @ basis.T
    coords = project(points)
    predicted = project(analysis['metrics']['prediction'])
    c_index = 2 * len(analysis['pairs'])
    palette = qualitative.Plotly
    fig = go.Figure()
    scatter = go.Scatter if dimensions == 2 else go.Scatter3d

    def add_points(coordinates, **kwargs):
        axes = {'x': coordinates[:, 0], 'y': coordinates[:, 1]}
        if dimensions == 3:
            axes['z'] = coordinates[:, 2]
        fig.add_trace(scatter(**axes, **kwargs))

    extent = max(float(np.linalg.norm(np.ptp(np.vstack([coords, predicted]), axis=0))), .01)

    def arrow(start, end, label, color, dash='solid', showlegend=True, group=None):
        group = group or label
        add_points(np.array([start, end]), mode='lines', name=label, showlegend=showlegend,
                   legendgroup=group,
                   line={'color': color, 'width': 4 if dimensions == 3 else 2, 'dash': dash},
                   hoverinfo='name')
        if np.linalg.norm(end - start) < 1e-12:
            return
        if dimensions == 2:
            fig.add_annotation(x=end[0], y=end[1], ax=start[0], ay=start[1], xref='x', yref='y',
                               axref='x', ayref='y', text='', showarrow=True, arrowhead=3,
                               arrowwidth=2, arrowcolor=color)
        else:
            delta = end - start
            fig.add_trace(go.Cone(x=[end[0]], y=[end[1]], z=[end[2]], u=[delta[0]], v=[delta[1]], w=[delta[2]],
                                 anchor='tip', sizemode='absolute', sizeref=extent * .045,
                                 colorscale=[[0, color], [1, color]], showscale=False, hoverinfo='skip',
                                 legendgroup=group, showlegend=False))

    for i in range(len(analysis['pairs'])):
        arrow(coords[i * 2], coords[i * 2 + 1], 'Example offset' if len(analysis['pairs']) == 1 else 'Example offsets',
              palette[0], showlegend=i == 0, group='examples')
    arrow(coords[c_index], predicted, 'C + mean(B − A)', palette[2], 'dash')
    if analysis['expected'] is not None:
        arrow(coords[c_index], coords[c_index + 1], 'C → D', palette[1])
        arrow(predicted, coords[c_index + 1], 'Prediction error', palette[3], 'dot')
    add_points(coords, mode='markers+text', text=point_codes, textposition='top center',
               customdata=point_labels, name='Input vectors',
               marker={'size': 7 if dimensions == 2 else 4, 'color': palette[0]},
               hovertemplate='%{text}: %{customdata}<extra></extra>')
    add_points(predicted[None, :], mode='markers+text', text=['Predicted'], textposition='bottom center',
               name='Predicted D', marker={'size': 10 if dimensions == 2 else 6, 'symbol': 'diamond', 'color': palette[2]},
               hovertemplate='Predicted D = C + mean(B − A)<extra></extra>')
    extra = [key for key in (extra_vectors or {}) if key not in point_keys]
    if extra:
        extra_coords = project([extra_vectors[key] for key in extra])
        add_points(extra_coords, mode='markers', customdata=[extra_labels[key] for key in extra],
                   name='Other chart vectors', marker={'size': 5 if dimensions == 2 else 3, 'color': palette[4]},
                   hovertemplate='%{customdata}<extra></extra>')
    fig.update_layout(template='plotly_dark', autosize=True,
                      title={'text': f'PCA · {retained:.1%} of input variance retained', 'font': {'size': 13}, 'x': .01, 'y': .98, 'yanchor': 'top'},
                      margin={'l': 20, 'r': 20, 't': 72, 'b': 25},
                      legend={'orientation': 'h', 'y': 1.02, 'yanchor': 'bottom'},
                      uirevision=f'relationship:{analysis["id"]}:{dimensions}')
    if dimensions == 2:
        fig.update_xaxes(title='PC 1')
        fig.update_yaxes(title='PC 2', scaleanchor='x', scaleratio=1)
    else:
        fig.update_layout(scene={'xaxis_title': 'PC 1', 'yaxis_title': 'PC 2', 'zaxis_title': 'PC 3', 'aspectmode': 'data'})
    return fig