
<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://dherrera1911.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://dherrera1911.github.io/" rel="alternate" type="text/html" /><updated>2025-11-07T15:26:47-08:00</updated><id>https://dherrera1911.github.io/feed.xml</id><title type="html">Daniel Herrera-Esposito</title><subtitle>Postdoctoral researcher at University of Pennsylvania</subtitle><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><entry><title type="html">Neural Coding Open Datasets: Python Tutorial Part 1</title><link href="https://dherrera1911.github.io/posts/2025/08/open-neural-coding-datasets/" rel="alternate" type="text/html" title="Neural Coding Open Datasets: Python Tutorial Part 1" /><published>2025-08-10T00:00:00-07:00</published><updated>2025-08-10T00:00:00-07:00</updated><id>https://dherrera1911.github.io/posts/2025/08/open-neural-coding</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2025/08/open-neural-coding-datasets/"><![CDATA[<p>Understanding how populations of neurons encode information is a
central goal of systems neuroscience.
As neural recordings scale in magnitude, 
there is a growing need for novel statistical methods
to analyze these data.
Open neuroscience datasets are a great tool for developing and testing
such methods. This post kicks off a short series on
working with some open neural population datasets using Python.
We’ll focus on one core task: <strong>obtaining spike counts across trials and conditions</strong>.
This first post uses the
<a href="https://portal.brain-map.org/circuits-behavior/visual-coding-neuropixels">Allen Institute - Visual Coding</a>
dataset, accessed via the AllenSDK package.</p>

<p>We’ll:</p>

<ul>
  <li>Explore the dataset using AllenSDK</li>
  <li>Download data for one session</li>
  <li>Filter the data by stimulus presented and neuron properties</li>
  <li>Extract a spike count matrix and condition labels</li>
  <li>Visualize and decode conditions using LDA</li>
</ul>

<p>We’ll also run a quick supervised dimensionality reduction
and decoding pipeline using Linear Discriminant Analysis (LDA).</p>

<h2 id="motivation">Motivation</h2>

<p>There are many open datasets and data tools
for systems neuroscience.
While valuable, it can also be overwhelming to navigate the
options and their complex documentations.</p>

<p>This post aims to provide an example of relatively simple
code to achieve a specific task:
<strong>obtain spike counts across trials and conditions</strong>.
Our end goal is to obtain:</p>
<ul>
  <li>A matrix <code class="language-plaintext highlighter-rouge">X</code> of shape <code class="language-plaintext highlighter-rouge">(trials, neurons)</code> with spike counts</li>
  <li>A vector <code class="language-plaintext highlighter-rouge">y</code> of length <code class="language-plaintext highlighter-rouge">trials</code> with condition labels</li>
</ul>

<p>This post will not explain the details of standard
Python packages such as <code class="language-plaintext highlighter-rouge">pandas</code>, nor the intricacies of
the AllenSDK package or the
<a href="https://nwb.org/">Neurodata Without Borders</a> (NWB) format.
Instead, it will focus on the steps to obtain the spike counts
and condition labels.</p>

<h2 id="allen-institute---brain-observatory">Allen Institute - Brain Observatory</h2>

<p>We’ll use the Allen Institute 
<a href="https://portal.brain-map.org/circuits-behavior/visual-coding-neuropixels">Visual Coding - Neuropixels</a>
dataset. This dataset
contains high-density recordings from the mouse brain
during visual stimulation experiments, using the Neuropixels probe
(one of the latest technologies for large neural recordings).</p>

<p>This is a large dataset with many sessions and 855 GB of
NWB files. To interface conveniently with the data, we will use
the Python package <a href="https://allensdk.readthedocs.io/en/latest/visual_coding_neuropixels.html">AllenSDK</a>,
developed by the Allen Institute.</p>

<p>First, let’s install the package using <code class="language-plaintext highlighter-rouge">pip</code> in the
command line (we recommend using a virtual environment):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>allensdk
</code></pre></div></div>

<p>Next, we use the package to access the dataset.</p>

<h2 id="the-allensdk-cache-and-downloading-single-session-data">The AllenSDK cache and downloading single session data</h2>

<p>The <code class="language-plaintext highlighter-rouge">allensdk</code> package provides a cache system to manage the
data. For example, it allows us to obtain the metadata for
the sessions before downloading them.</p>

<p>The tool for this is an object called <code class="language-plaintext highlighter-rouge">EcephysProjectCache</code>.
Let’s create the cache object and use it to download the
metadata for the sessions:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">numpy</span> <span class="k">as</span> <span class="n">np</span>
<span class="kn">from</span> <span class="nn">allensdk.brain_observatory.ecephys.ecephys_project_cache</span> <span class="kn">import</span> <span class="n">EcephysProjectCache</span>

<span class="n">output_dir</span> <span class="o">=</span> <span class="s">'./allen_data/allen_cache_dir'</span>  <span class="c1"># Where the data will be stored locally
</span><span class="n">manifest_path</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">output_dir</span><span class="p">,</span> <span class="s">"manifest.json"</span><span class="p">)</span>

<span class="c1"># Create the cache object, that will manage the data
</span>
<span class="n">cache</span> <span class="o">=</span> <span class="n">EcephysProjectCache</span><span class="p">.</span><span class="n">from_warehouse</span><span class="p">(</span><span class="n">manifest</span><span class="o">=</span><span class="n">manifest_path</span><span class="p">)</span>

<span class="c1"># Use the cache to get the session table
# This will create the output_dir above if it doesn't exist,
# and save the session metadata there.
</span>
<span class="n">sessions</span> <span class="o">=</span> <span class="n">cache</span><span class="p">.</span><span class="n">get_session_table</span><span class="p">()</span>
</code></pre></div></div>

<p>The new <code class="language-plaintext highlighter-rouge">sessions</code> variable is a Pandas DataFrame
with information about each session, like the mouse age,
the experimental protocol, and the session ID. Let’s
print the first few rows to see what we have:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">print</span><span class="p">(</span><span class="n">sessions</span><span class="p">.</span><span class="n">head</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Column names: </span><span class="si">{</span><span class="n">sessions</span><span class="p">.</span><span class="n">columns</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                   published_at  specimen_id  ... probe_count                         ecephys_structure_acronyms
id                                            ...                                                               
715093703  2019-10-03T00:00:00Z    699733581  ...           6  [CA1, VISrl, nan, PO, LP, LGd, CA3, DG, VISl, ...
719161530  2019-10-03T00:00:00Z    703279284  ...           6  [TH, Eth, APN, POL, LP, DG, CA1, VISpm, nan, N...
721123822  2019-10-03T00:00:00Z    707296982  ...           6  [MB, SCig, PPT, NOT, DG, CA1, VISam, nan, LP, ...
732592105  2019-10-03T00:00:00Z    717038288  ...           5       [grey, VISpm, nan, VISp, VISl, VISal, VISrl]
737581020  2019-10-03T00:00:00Z    718643567  ...           6      [grey, VISmma, nan, VISpm, VISp, VISl, VISrl]

Column names: Index(['published_at', 'specimen_id', 'session_type', 'age_in_days', 'sex',
       'full_genotype', 'unit_count', 'channel_count', 'probe_count',
       'ecephys_structure_acronyms'],
      dtype='object')
</code></pre></div></div>

<p>Now, let’s use the cache to download the actual neural recordings
data for a specific session. We filter the sessions to the ones
with the <code class="language-plaintext highlighter-rouge">brain_observatory_1.1</code> protocol, and
obtain the ID of the session in the 21st row.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sessions</span> <span class="o">=</span> <span class="n">sessions</span><span class="p">[(</span><span class="n">sessions</span><span class="p">.</span><span class="n">session_type</span><span class="o">==</span><span class="s">'brain_observatory_1.1'</span><span class="p">)]</span>  <span class="c1"># Filter sessions
</span><span class="n">ind</span> <span class="o">=</span> <span class="n">sessions</span><span class="p">.</span><span class="n">index</span><span class="p">.</span><span class="n">values</span><span class="p">[</span><span class="mi">21</span><span class="p">]</span>  <span class="c1"># Get the session ID
</span><span class="n">my_ses</span> <span class="o">=</span> <span class="n">cache</span><span class="p">.</span><span class="n">get_session_data</span><span class="p">(</span><span class="n">ind</span><span class="p">)</span>  <span class="c1"># Download the data for that session
</span></code></pre></div></div>

<p>The object <code class="language-plaintext highlighter-rouge">my_ses</code> is of the class <code class="language-plaintext highlighter-rouge">EcephysSession</code>,
and it allows us to conveniently access the data, as shown
below (see also <a href="https://allensdk.readthedocs.io/en/latest/_static/examples/nb/ecephys_session.html">this tutorial from AllenSDK</a>).</p>

<h2 id="filtering-trials-by-stimulus-properties">Filtering trials by stimulus properties</h2>

<p>Each session contains many types of stimuli, like
static gratings, gabors, natural images and natural movies,
as shown in the diagram below (obtained from
<a href="https://brainmapportal-live-4cc80a57cd6e400d854-f7fdcae.divio-media.net/filer_public/0f/5d/0f5d22c9-f8f6-428c-9f7a-2983631e72b4/neuropixels_cheat_sheet_nov_2019.pdf">this cheat sheet</a>).</p>

<p><img src="/files/blog/neural_data/allen_drawing.png" alt="" /></p>

<p>Here, we’ll focus on responses to <strong>static gratings</strong>, which vary in:</p>
<ul>
  <li>Orientation (0, 30, 60, 90, 120, and 150 degrees)</li>
  <li>Spatial frequency (0.02, 0.04, 0.08, 0.16 and 0.32 cycles/degree)</li>
  <li>Phase (0, 0.25, 0.5 and 0.75 periods)</li>
</ul>

<p>To select the trials with static gratings, we use
the stimulus information table (a Pandas DataFrame), available as
<code class="language-plaintext highlighter-rouge">my_ses.stimulus_presentations</code>, which we assign to the variable <code class="language-plaintext highlighter-rouge">stim_table</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">stim_table</span> <span class="o">=</span> <span class="n">my_ses</span><span class="p">.</span><span class="n">stimulus_presentations</span>
<span class="k">print</span><span class="p">(</span><span class="n">stim_table</span><span class="p">.</span><span class="n">head</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Column names: </span><span class="si">{</span><span class="n">stim_table</span><span class="p">.</span><span class="n">columns</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         stimulus_block  start_time  stop_time  ...          size   duration stimulus_condition_id
stimulus_presentation_id                                        ...                                               
0                                  null   24.752216  84.818986  ...          null  60.066770                     0
1                                   0.0   84.818986  85.052505  ...  [20.0, 20.0]   0.233520                     1
2                                   0.0   85.052505  85.302704  ...  [20.0, 20.0]   0.250199                     2
3                                   0.0   85.302704  85.552904  ...  [20.0, 20.0]   0.250199                     3
4                                   0.0   85.552904  85.803103  ...  [20.0, 20.0]   0.250199                     4

[5 rows x 16 columns]

Column names: Index(['stimulus_block', 'start_time', 'stop_time', 'temporal_frequency',
       'spatial_frequency', 'contrast', 'phase', 'stimulus_name', 'x_position',
       'frame', 'color', 'y_position', 'orientation', 'size', 'duration',
       'stimulus_condition_id'],
      dtype='object')
</code></pre></div></div>

<p>To obtain the desired trials, we need to extract their trial
index from <code class="language-plaintext highlighter-rouge">stim_table</code>. For that, let’s use
Pandas to filter the DataFrame by <code class="language-plaintext highlighter-rouge">stimulus_name</code>, and also
exclude trials with a null stimulus (i.e., where no stimulus was presented).</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Number of trials before filtering: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">stim_table</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

<span class="n">stim_table</span> <span class="o">=</span> <span class="n">stim_table</span><span class="p">[</span>
  <span class="p">(</span><span class="n">stim_table</span><span class="p">.</span><span class="n">stimulus_name</span> <span class="o">==</span> <span class="s">"static_gratings"</span><span class="p">)</span> <span class="o">&amp;</span> \
  <span class="p">(</span><span class="n">stim_table</span><span class="p">.</span><span class="n">orientation</span> <span class="o">!=</span> <span class="s">"null"</span><span class="p">)</span>
<span class="p">]</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Number of trials after filtering: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">stim_table</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Number of trials before filtering: 70390
Number of trials after filtering: 5811
</code></pre></div></div>

<p>Let’s further simplify our dataset by keeping only
one spatial frequency (0.08 cycles/degree) and
one phase (0 degrees). (This is an arbitrary choice,
you might want to keep more conditions for your analyses.)</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">stim_table</span> <span class="o">=</span> <span class="n">stim_table</span><span class="p">[</span>
  <span class="p">(</span><span class="n">stim_table</span><span class="p">.</span><span class="n">spatial_frequency</span> <span class="o">==</span> <span class="mf">0.08</span><span class="p">)</span> <span class="o">&amp;</span> \
  <span class="p">(</span><span class="n">stim_table</span><span class="p">.</span><span class="n">phase</span> <span class="o">!=</span> <span class="mi">0</span><span class="p">)</span>
<span class="p">]</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Final number of trials: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">stim_table</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Number of trials after filtering: 1163
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">stim_table</code> DataFrame now has only the trials that we want.
Let’s extract the trial indices that we will use shortly to
obtain the neural data:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Array with indices of desired trials
</span><span class="n">trial_inds</span> <span class="o">=</span> <span class="n">stim_table</span><span class="p">.</span><span class="n">index</span><span class="p">.</span><span class="n">values</span>
</code></pre></div></div>

<h2 id="filtering-neurons-by-brain-area-and-firing-rate">Filtering neurons by brain area and firing rate</h2>

<p>Also, each session contains hundreds of neurons recorded across
many brain areas. Since we are interested in visual coding, let’s
just keep the neurons from the primary visual cortex (“VISp”).
We’ll also focus on neurons with a firing rate above a certain threshold.
(See this <a href="https://allensdk.readthedocs.io/en/latest/_static/examples/nb/ecephys_quality_metrics.html">quality metrics tutorial</a>
from AllenSDK).</p>

<p>Like for the stimulus information, the neurons information is
available as a Pandas DataFrame in <code class="language-plaintext highlighter-rouge">my_ses.units</code>. We assign
this DataFrame to the variable <code class="language-plaintext highlighter-rouge">units_table</code> for convenience.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">units_table</span> <span class="o">=</span> <span class="n">my_ses</span><span class="p">.</span><span class="n">units</span>

<span class="k">print</span><span class="p">(</span><span class="n">units_table</span><span class="p">.</span><span class="n">head</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Column names: </span><span class="si">{</span><span class="n">units_table</span><span class="p">.</span><span class="n">columns</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Number of neurons: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">units_table</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>           waveform_PT_ratio  waveform_amplitude  ...  probe_lfp_sampling_rate  probe_has_lfp_data
unit_id                                           ...                                             
951853372           0.510700          232.788465  ...              1249.998592                True
951853379           2.929978           82.579965  ...              1249.998592                True
951853388           0.410656           96.195255  ...              1249.998592                True
951853498           0.434301          103.250355  ...              1249.998592                True
951853596           0.323884           70.187910  ...              1249.998592                True

[5 rows x 40 columns]
Column names: Index(['waveform_PT_ratio', 'waveform_amplitude', 'amplitude_cutoff',
       'cluster_id', 'cumulative_drift', 'd_prime', 'firing_rate',
       'isi_violations', 'isolation_distance', 'L_ratio', 'local_index',
       'max_drift', 'nn_hit_rate', 'nn_miss_rate', 'peak_channel_id',
       'presence_ratio', 'waveform_recovery_slope',
       'waveform_repolarization_slope', 'silhouette_score', 'snr',
       'waveform_spread', 'waveform_velocity_above', 'waveform_velocity_below',
       'waveform_duration', 'filtering', 'probe_channel_number',
       'probe_horizontal_position', 'probe_id', 'probe_vertical_position',
       'structure_acronym', 'ecephys_structure_id',
       'ecephys_structure_acronym', 'anterior_posterior_ccf_coordinate',
       'dorsal_ventral_ccf_coordinate', 'left_right_ccf_coordinate',
       'probe_description', 'location', 'probe_sampling_rate',
       'probe_lfp_sampling_rate', 'probe_has_lfp_data'],
      dtype='object')
Number of neurons: 501
</code></pre></div></div>

<p>Let’s see what brain areas are present in the dataset.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">print</span><span class="p">(</span><span class="s">"Brain areas in the dataset:"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">units_table</span><span class="p">.</span><span class="n">ecephys_structure_acronym</span><span class="p">.</span><span class="n">unique</span><span class="p">())</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Brain areas in the dataset:
['DG' 'CA1' 'VISam' 'LP' 'VISpm' 'LGd' 'VISp' 'CA3' 'CA2' 'VISl' 'MB' 'TH'
 'PP' 'PIL' 'VISal']
</code></pre></div></div>

<p>Let’s get the indices of the units that are in the primary visual
cortex (VISp), and that have a firing rate above 3 Hz.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">min_fr</span> <span class="o">=</span> <span class="mf">3.0</span>  <span class="c1"># Minimum firing rate in Hz
</span><span class="n">units_table</span> <span class="o">=</span> <span class="n">units_table</span><span class="p">[</span>
  <span class="p">(</span><span class="n">np</span><span class="p">.</span><span class="n">isin</span><span class="p">(</span><span class="n">units_table</span><span class="p">.</span><span class="n">structure_acronym</span><span class="p">,</span> <span class="s">"VISp"</span><span class="p">)</span> <span class="o">*</span> \
  <span class="n">units_table</span><span class="p">.</span><span class="n">firing_rate</span> <span class="o">&gt;</span> <span class="n">min_fr</span><span class="p">)</span>
<span class="p">]</span>
<span class="n">v1_inds</span> <span class="o">=</span> <span class="n">units_table</span><span class="p">.</span><span class="n">index</span><span class="p">.</span><span class="n">values</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Number of neurons in V1 with firing rate &gt; </span><span class="si">{</span><span class="n">min_fr</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">v1_inds</span><span class="p">)</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Number of neurons in V1 with firing rate &gt; 3.0: 45
</code></pre></div></div>

<h2 id="obtaining-the-spike-counts">Obtaining the spike counts</h2>

<p>We have the IDs of the trials and neurons that we want.
The next step is to pass them to the method
<code class="language-plaintext highlighter-rouge">my_ses.presentationwise_spike_counts()</code>, to obtain
the population responses. We can also pass a
<code class="language-plaintext highlighter-rouge">bin_edges</code> argument to specify the time window
for counting spikes. Here we use a window from 0.01
to 0.25 seconds after stimulus onset.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">X</span> <span class="o">=</span> <span class="n">my_ses</span><span class="p">.</span><span class="n">presentationwise_spike_counts</span><span class="p">(</span>
  <span class="n">bin_edges</span><span class="o">=</span><span class="p">(</span><span class="mf">0.01</span><span class="p">,</span> <span class="mf">0.25</span><span class="p">),</span>
  <span class="n">stimulus_presentation_ids</span><span class="o">=</span><span class="n">trial_inds</span><span class="p">,</span>
  <span class="n">unit_ids</span><span class="o">=</span><span class="n">v1_inds</span>
<span class="p">).</span><span class="n">values</span><span class="p">.</span><span class="n">squeeze</span><span class="p">()</span>

<span class="k">print</span><span class="p">(</span><span class="n">X</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Shape of spike counts array: </span><span class="si">{</span><span class="n">X</span><span class="p">.</span><span class="n">shape</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[[2 0 2 ... 0 5 4]
 [6 1 1 ... 0 0 6]
 [5 1 4 ... 0 1 7]
 ...
 [5 5 4 ... 0 2 5]
 [0 2 1 ... 2 1 3]
 [0 4 0 ... 0 5 9]]

Shape of spike counts array: (1163, 45)
</code></pre></div></div>

<p>The variable <code class="language-plaintext highlighter-rouge">X</code> now contains the spike counts for
our 45 neurons across the 1163 selected trials.</p>

<p>Now we should obtain the labels for each trial.
Since we fixed the spatial frequency and phase,
we’ll use the orientations that we can extract
from <code class="language-plaintext highlighter-rouge">stim_table</code>, and convert them to integer labels
using <code class="language-plaintext highlighter-rouge">np.unique()</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">ori</span> <span class="o">=</span> <span class="n">stim_table</span><span class="p">.</span><span class="n">orientation</span><span class="p">.</span><span class="n">values</span>
<span class="c1"># Convert orientations to integer labels
</span><span class="n">values</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">unique</span><span class="p">(</span><span class="n">ori</span><span class="p">,</span> <span class="n">return_inverse</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Orientations: </span><span class="si">{</span><span class="n">values</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Shape of labels array: </span><span class="si">{</span><span class="n">y</span><span class="p">.</span><span class="n">shape</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Orientations: [0.0 30.0 60.0 90.0 120.0 150.0]
Shape of labels array: (1163,)
</code></pre></div></div>

<p>Now the labels are in <code class="language-plaintext highlighter-rouge">y</code>, which has the same length
as the number of trials in <code class="language-plaintext highlighter-rouge">X</code>.</p>

<h2 id="simple-decoding-and-visualization-analysis">Simple decoding and visualization analysis</h2>

<p>Let’s apply a simple analysis to our data
using Linear Discriminant Analysis (LDA).</p>

<p>LDA is a standard technique that can be used for both decoding and
for visualization of data with multiple classes (see my
<a href="https://dherrera1911.github.io/posts/2025/01/lda-what-you-should-know/">previous post about LDA</a>).</p>

<p>Specifically, we’ll use LDA to find
the two dimensions that best separate the different gratings
in neural response space. We’ll see what performance we
get for decoding orientations from these features, and visualize
the neural responses in the new feature space.</p>

<p>We’ll assume that the <code class="language-plaintext highlighter-rouge">sklearn</code> package is installed.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">sklearn.discriminant_analysis</span> <span class="kn">import</span> <span class="n">LinearDiscriminantAnalysis</span>
<span class="kn">from</span> <span class="nn">sklearn.model_selection</span> <span class="kn">import</span> <span class="n">cross_val_score</span><span class="p">,</span> <span class="n">train_test_split</span>
<span class="kn">import</span> <span class="nn">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>

<span class="c1"># Fit LDA with 2 components
</span><span class="n">lda</span> <span class="o">=</span> <span class="n">LinearDiscriminantAnalysis</span><span class="p">(</span><span class="n">solver</span><span class="o">=</span><span class="s">'eigen'</span><span class="p">,</span> <span class="n">shrinkage</span><span class="o">=</span><span class="mf">0.2</span><span class="p">,</span> <span class="n">n_components</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>

<span class="c1"># Define color map
</span><span class="n">X_lda</span> <span class="o">=</span> <span class="n">lda</span><span class="p">.</span><span class="n">fit_transform</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
<span class="n">colors</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">cm</span><span class="p">.</span><span class="n">tab10</span><span class="p">.</span><span class="n">colors</span>  <span class="c1"># up to 10 distinguishable colors
</span>
<span class="n">plt</span><span class="p">.</span><span class="n">figure</span><span class="p">(</span><span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">8</span><span class="p">,</span> <span class="mi">6</span><span class="p">))</span>
<span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">values</span><span class="p">):</span>
    <span class="n">plt</span><span class="p">.</span><span class="n">scatter</span><span class="p">(</span>
        <span class="n">X_lda</span><span class="p">[</span><span class="n">y</span> <span class="o">==</span> <span class="n">i</span><span class="p">,</span> <span class="mi">0</span><span class="p">],</span>
        <span class="n">X_lda</span><span class="p">[</span><span class="n">y</span> <span class="o">==</span> <span class="n">i</span><span class="p">,</span> <span class="mi">1</span><span class="p">],</span>
        <span class="n">color</span><span class="o">=</span><span class="n">colors</span><span class="p">[</span><span class="n">i</span> <span class="o">%</span> <span class="nb">len</span><span class="p">(</span><span class="n">colors</span><span class="p">)],</span>
        <span class="n">label</span><span class="o">=</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">label</span><span class="si">:</span><span class="p">.</span><span class="mi">0</span><span class="n">f</span><span class="si">}</span><span class="s">°"</span><span class="p">,</span>
        <span class="n">alpha</span><span class="o">=</span><span class="mf">0.6</span><span class="p">,</span>
        <span class="n">edgecolor</span><span class="o">=</span><span class="s">"k"</span><span class="p">,</span>
        <span class="n">s</span><span class="o">=</span><span class="mi">40</span>
    <span class="p">)</span>

<span class="n">plt</span><span class="p">.</span><span class="n">xlabel</span><span class="p">(</span><span class="s">"LDA 1"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">ylabel</span><span class="p">(</span><span class="s">"LDA 2"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">title</span><span class="p">(</span><span class="s">"Neural responses projected into LDA space (2D)"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">legend</span><span class="p">(</span><span class="n">title</span><span class="o">=</span><span class="s">"Orientation"</span><span class="p">,</span> <span class="n">bbox_to_anchor</span><span class="o">=</span><span class="p">(</span><span class="mf">1.05</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="n">loc</span><span class="o">=</span><span class="s">'upper left'</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>
<span class="n">plt</span><span class="p">.</span><span class="n">grid</span><span class="p">(</span><span class="bp">True</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/neural_data/lda_neuro.png" alt="" /></p>

<p>In the plot above, the two axes are two dimensions of neural
response space. We can also consider them population activity modes.
Each point corresponds to one trial, and the color indicates
the orientation of the grating. We see that the classes are pretty
well separated, even in this 2D space. Let’s evaluate how well we can
decode orientation using the LDA classifier, which amounts to
a linear classifier in the space above:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Evaluate decoding performance using cross-validation on 2D projections
</span><span class="n">lda</span> <span class="o">=</span> <span class="n">LinearDiscriminantAnalysis</span><span class="p">(</span><span class="n">solver</span><span class="o">=</span><span class="s">'eigen'</span><span class="p">,</span> <span class="n">shrinkage</span><span class="o">=</span><span class="mf">0.2</span><span class="p">,</span> <span class="n">n_components</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
<span class="n">scores</span> <span class="o">=</span> <span class="n">cross_val_score</span><span class="p">(</span><span class="n">lda</span><span class="p">,</span> <span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">cv</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Mean LDA accuracy (on 2D projection, 5-fold CV): </span><span class="si">{</span><span class="n">scores</span><span class="p">.</span><span class="n">mean</span><span class="p">()</span><span class="si">:</span><span class="p">.</span><span class="mi">3</span><span class="n">f</span><span class="si">}</span><span class="s"> ± </span><span class="si">{</span><span class="n">scores</span><span class="p">.</span><span class="n">std</span><span class="p">()</span><span class="si">:</span><span class="p">.</span><span class="mi">3</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Mean LDA accuracy (on 2D projection, 5-fold CV): 0.931 ± 0.029
</code></pre></div></div>

<p>The result above shows that we can decode the orientation of
the grating to a high accuracy using only the first two LDA components.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this post we showed how to access the Allen Institute
Visual Coding - Neuropixels dataset using the <code class="language-plaintext highlighter-rouge">allensdk</code> package.
We obtained the spike count responses of a population of neurons
to static gratings. We used this dataset to do supervised
dimensionality reduction and neural decoding using LDA.</p>

<p>Future posts will cover other datasets and data formats.</p>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Neuroscience" /><category term="Open Data" /><category term="Python" /><category term="Neural Coding" /><summary type="html"><![CDATA[Understanding how populations of neurons encode information is a central goal of systems neuroscience. As neural recordings scale in magnitude, there is a growing need for novel statistical methods to analyze these data. Open neuroscience datasets are a great tool for developing and testing such methods. This post kicks off a short series on working with some open neural population datasets using Python. We’ll focus on one core task: obtaining spike counts across trials and conditions. This first post uses the Allen Institute - Visual Coding dataset, accessed via the AllenSDK package.]]></summary></entry><entry><title type="html">I thought I understood LDA, until I learned this</title><link href="https://dherrera1911.github.io/posts/2025/01/lda-what-you-should-know/" rel="alternate" type="text/html" title="I thought I understood LDA, until I learned this" /><published>2025-02-13T00:00:00-08:00</published><updated>2025-02-13T00:00:00-08:00</updated><id>https://dherrera1911.github.io/posts/2025/01/lda-what-you-should-know</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2025/01/lda-what-you-should-know/"><![CDATA[<p>Sometimes you think you understand a basic
statistics problem. And then, you look closer
and realize that you don’t understand it as well as you thought.
This happened to me recently with Linear Discriminant Analysis (LDA).
In this post, I discuss some interesting and lesser known
aspects of LDA that I learned when diving deeper into this
method, and that seem to often cause confusion.</p>

<p>Some of the lesser known facts about LDA that we will discuss here are:</p>
<ul>
  <li>LDA refers to both a linear classifier and a separate dimensionality
reduction method (our focus here)</li>
  <li>The dimensionality reduction method has a well known intuition, but
different mathematical objectives are often used for this objective</li>
  <li>Some of the different mathematical objectives are equivalent, but others are not</li>
  <li>We prove that the LDA features are the eigenvectors of the matrix
\(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\) where
\(\mathbf{W}_{\mathbf{x}}\) is the within-class scatter matrix and
\(\mathbf{B}_{\mathbf{x}}\) is the between-class scatter matrix</li>
  <li>LDA eigenvector features whiten the within-class covariance
and diagonalize the between-class covariance</li>
  <li>LDA features do not necessarily maximize the performance of
the LDA classifier</li>
</ul>

<h2 id="what-is-lda-a-tale-of-two-methods">What is LDA? A tale of two methods</h2>

<p>This first question can already be a source for confusion.
The term LDA is commonly used to refer to two different but
related techniques: 1) A linear classifier, and 2) a supervised
dimensionality reduction method. This post is about the dimensionality
reduction method, but it will be useful to first outline the classifier.</p>

<p>For both methods we will assume that we have a labeled dataset,
with data vectors \(\{\mathbf{x}_1, \ldots, \mathbf{x}_N\}\) and
labels \(\{y_1, \ldots, y_N\}\), with each \(\mathbf{x}_q \in \mathbb{R}^n\)
and each \(y_q \in \{1, \ldots, c\}\). Here, \(N\) is the number of
data points, \(n\) the dimensionality, and \(c\) is the number
of classes.</p>

<h3 id="the-lda-classifier">The LDA classifier</h3>

<p>The LDA classifier (how we’ll refer to this version of LDA onwards)
is theoretically simple. It makes two essential assumptions:</p>
<ol>
  <li>That the distribution of the data conditional on the
classes is Gaussian.</li>
  <li>That all the classes have the same covariance (homoscedasticity)</li>
</ol>

<p>In mathematical terms, LDA assumes that
\(p(\mathbf{x}|y=k) = \mathcal{N}(\bar{\mathbf{x}}_k, \mathbf{W}_{\mathbf{x}})\), where
\(\bar{\mathbf{x}}_k\) is the mean of the class \(i\), and \(\mathbf{W}_{\mathbf{x}}\)
is the covariance matrix within each class, that is
the same for all classes (this choice of notation will come handy later).</p>

<p>Under this assumption, the LDA classifier estimates the labels of
new observations \(\mathbf{x}_q\) by computing the likelihoods
\(p(\mathbf{x}_q|y=k)\) for each class, possibly combining
them with a prior \(p(y=k)\), and then selecting the class
with the highest likelihood or
posterior probability. If all classes have the same priors,
the estimated class is the one whose mean \(\bar{\mathbf{x}}_k\) is closest
(in terms of the Mahalanobis distance) to the observations \(\mathbf{x}_q\).
Because of the homoscedasticity assumption, this procedure leads
to linear decision boundaries between the classes.</p>

<h3 id="for-the-lda-classifier-only-a-subspace-of-the-data-matters">For the LDA classifier, only a subspace of the data matters</h3>

<p>The LDA classifier has an interesting geometric implication
for the data. In the \(n\)-dimensional space of the data,
the class means \(\{\bar{\mathbf{x}}_1, \ldots, \bar{\mathbf{x}}_c\}\)
will lie in a subspace of at most \(c-1\) dimensions.
As we mentioned, for a given observation \(\mathbf{x}_q\),
the LDA classifier will estimate the class by 
finding the closest class mean \(\bar{\mathbf{x}}_k\).
Geometrically, the only relevant information
for the LDA classifier is the projection of the data onto the
subspace spanned by the means. The component of the
data orthogonal to this subspace will not affect 
the classifier output, because they do not change
what mean is closest to \(\mathbf{x}_q\).</p>

<p>Therefore, the LDA classifier implies a dimensionality reduction
from \(n\) to \(c-1\) dimensions. This is not the same as the
LDA dimensionality reduction method, however, as we will
see next.</p>

<h3 id="lda-dimensionality-reduction-the-intuition">LDA dimensionality reduction: The intuition</h3>

<p>We’ll refer to the LDA dimensionality reduction method as
<strong>LDA-DR</strong>.</p>

<p>So, how is LDA-DR different from the dimensionality
reduction implied by the LDA classifier?
For this it is useful to consider some limitations of the
dimensionality reduction implied by the LDA classifier:</p>
<ol>
  <li>The LDA classifier provides a \(c-1\) dimensional subspace,
but we sometimes want to have a lower number of dimensions \(m &lt; c-1\)</li>
  <li>The LDA classifier does not provide specific filters that can
be analyzed</li>
  <li>The LDA classifier does not provide an ordering of the
reduced space dimensions by relevance</li>
</ol>

<p>The LDA-DR method addresses these limitations by providing a
method for learning an \(m\)-dimensional feature space
(with \(m \leq c-1\)) with \(m\) filters that are ordered by relevance.</p>

<p>Intuitively, LDA-DR achieves this by finding the filters that
maximize the separation between the class means (or the between-class
variance) while minimizing the within-class variance, in the
feature space. The intuition is that classes that are farther from
one another should be better separated by a linear classifier.
The features can be ordered by the ratio of between-class
variance to within-class variance of the data projected to
each filter.</p>

<h2 id="lda-dimensionality-reduction-has-different-possible-objectives">LDA dimensionality reduction has different possible objectives</h2>

<p>We now get to the tricky part: what does
it mean to maximize the between-class variance relative to
the within-class variance? There are different ways to
answer this question. Before we list the different alternatives,
lets define some quantities that they all use.</p>

<h3 id="between-class-scatter-and-within-class-scatter">Between-class scatter and within-class scatter</h3>

<p>First, we define the matrix \(\mathbf{F} \in \mathbb{R}^{n\times m}\)
where each column is a filter, and the transformed variable
\(\mathbf{z} = \mathbf{F}^T \mathbf{x}\). As mentioned,
the goal of LDA-DR
is to maximize the between-class variance of \(\mathbf{z}\)
while minimizing the within-class variance.</p>

<p>However, we need to define what we mean by “variance” for
an \(m\)-dimensional variable. For this, we first
need the between-class scatter matrix \(\mathbf{B}_{\mathbf{z}}\)
and the within-class scatter matrix \(\mathbf{W}_{\mathbf{z}}\),
defined as follows:</p>

\[\mathbf{B}_\mathbf{z} = \frac{1}{c} \sum_{k=1}^{c} (\bar{\mathbf{z}}_k - \bar{\mathbf{z}}) (\bar{\mathbf{z}}_k - \bar{\mathbf{z}})^T\]

<p>and</p>

\[\mathbf{W}_\mathbf{z} = \frac{1}{N-c} \sum_{k=1}^{c} \sum_{i \in \mathcal{C}_k} (\mathbf{z}_q - \bar{\mathbf{z}}_{k}) (\mathbf{z}_q - \bar{\mathbf{z}}_{k})^T\]

<p>where \(\mathbf{z}_q = \mathbf{F}^T \mathbf{x}_q\) is the transformed
data point \(q\), \(\mathcal{C}_k\) is the set of points belonging
to class \(k\) (this is just to say, to each point we subtract
the mean for its class), \(\bar{\mathbf{z}}_k\) is the mean of \(\mathbf{z}\)
for class \(k\) and \(\bar{\mathbf{z}}\) is the global mean of the transformed
dataset. Note that slightly different formulas can also be used
to account for the different number of data points in each class,
but we can ignore this for our purpose.</p>

<p>In words, \(\mathbf{B}_{\mathbf{z}}\) is the covariance matrix of
the class means and \(\mathbf{W}_{\mathbf{z}}\) is the residual
within-class covariance for the variable \(\mathbf{z}\).</p>

<p>With analogous formulas we can define \(\mathbf{B}_{\mathbf{x}}\)
and \(\mathbf{W}_{\mathbf{x}}\). Then, it is
easy to show that 
\(\mathbf{B}_{\mathbf{z}} = \mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F}\) 
and \(\mathbf{W}_{\mathbf{z}} = \mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F}\).</p>

<h3 id="different-ways-to-define-the-generalized-variance">Different ways to define the generalized variance</h3>

<p>Next, we need to summarize the scatter matrices into
a single scalar that relates to the “variance” of the data.
There are two main ways to do this. The first one is to use the
<strong>determinant</strong> of the scatter matrix, and the second one is to
use the <strong>trace</strong>.</p>

<p>The determinant of a scatter or covariance matrix is a measure
of the volume of the ellipsoid that the data points span. It is
also known as the generalized variance, and it is a well
known measure of the spread of the data.</p>

<p>The trace of the scatter or covariance matrix is the
sum of the variances in each dimension. Interestingly,
the trace of the scatter matrix for a variable \(\mathbf{z}\) with
respect to the centroid \(\bar{\mathbf{z}}\) is also
equal to \(\mathbb{E}[\|\mathbf{z} - \bar{\mathbf{z}}\|^2]\)
(this can be shown easily by using the definition of trace).
The expected value of squared deviations from the mean
is an intuitive measure of the spread of the data.</p>

<p>An important difference between these two
measures is that, for singular scatter matrices, the determinant
is zero but the trace is still defined. For example,
if we have more dimensions than data points, the determinant
of the scatter can’t give us any information because the scatter
matrix is singular, while the trace is still defined and will
give a measure of the spread of the data.</p>

<h3 id="the-different-lda-dr-objectives">The different LDA-DR objectives</h3>

<p>We can now define different ways to maximize the between-class variance
relative to the within-class variance.</p>

<p>The first way to define the LDA-DR objective uses the
determinant definition of generalized variance:</p>

\[J_1(\mathbf{F}) = \frac{\det(\mathbf{B}_{\mathbf{z}})}{\det(\mathbf{W}_{\mathbf{z}})} =
\frac{\det(\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F})}{
\det(\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})}\]

<p>The second way to define the LDA-DR objective uses the
trace definition:</p>

\[J_2(\mathbf{F}) = \text{Tr}(\mathbf{W}_{\mathbf{z}}^{-1}  \mathbf{B}_{\mathbf{z}})  =
\text{Tr} ( (\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})^{-1} (\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F}))\]

<p>The third common way to define the LDA-DR objective is to
maximize the ratio of the traces:</p>

\[J_3(\mathbf{F}) = \frac{Tr(\mathbf{B}_{\mathbf{z}})}{Tr(\mathbf{W}_{\mathbf{z}})} =
\frac{Tr(\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F})}{Tr(\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})}\]

<p>subject to the constraint that \(\mathbf{F}^T \mathbf{F} = \mathbf{I}\).</p>

<p>Interestingly, the first two objectives are equivalent, but
the third one is not. To see this, let’s find the solution for
\(J_1(\mathbf{F})\) and \(J_2(\mathbf{F})\). The analysis
below follows the book by Fukunaga,
<a href="https://cdn.preterhuman.net/texts/science_and_technology/artificial_intelligence/Pattern_recognition/Introduction%20to%20Statistical%20Pattern%20Recognition%202nd%20Ed%20-%20%20Keinosuke%20Fukunaga.pdf">“Introduction to Statistical Pattern Recognition”</a>.</p>

<h3 id="lda-features-for-j_1-are-the-eigenvectors-of-w_x-1-b_x">LDA features for \(J_1\) are the eigenvectors of \(W_x^{-1} B_x\)</h3>

<p>First, we find the solution for \(J_1(\mathbf{F})\). We will
prove the well-known result that the LDA-DR features are the
eigenvectors of \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\).
For this, we first take the logarithm of \(J_1(\mathbf{F})\), resulting
in the following equivalent objective:</p>

\[\log J_1(\mathbf{F}) = \log\left[\det(\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F}) \right] -
\log\left[ \det(\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F}) \right]\]

<p>We then take the derivative of this expression with respect to \(\mathbf{F}\):</p>

\[\frac{\partial \log J_1(\mathbf{F})}{\partial \mathbf{F}} =
2\left( \mathbf{B}_{\mathbf{x}} \mathbf{F} (\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F})^{-1} -
\mathbf{W}_{\mathbf{x}} \mathbf{F} (\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})^{-1} \right)\]

\[= 2 \left(\mathbf{B}_{\mathbf{x}} \mathbf{F} \mathbf{B}^{-1}_{\mathbf{z}} -
\mathbf{W}_{\mathbf{x}} \mathbf{F} \mathbf{W}^{-1}_{\mathbf{z}}\right)\]

<p>Setting the derivative to zero to find the maximum, and rearranging,
we get that \(\mathbf{F}\) must satisfy the following condition:</p>

\[\left( \mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}} \right) \mathbf{F}  = \mathbf{F}  \left( \mathbf{W}_{\mathbf{z}}^{-1} \mathbf{B}_{\mathbf{z}} \right)\]

<p>Now we will show that the filters \(\mathbf{F}\) are the
eigenvectors of \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\).</p>

<p>First, we note that for two symmetric matrices \(\mathbf{W}_{\mathbf{z}}\) and
\(\mathbf{B}_{\mathbf{z}}\), there exists an invertible matrix
\(\mathbf{Q} \in \mathbb{R}^{m \times m}\) that
simultaneously diagonalizes both matrices, such that
\(\mathbf{Q}^T \mathbf{W}_{\mathbf{z}} \mathbf{Q} = \mathbf{I}_m\)
and \(\mathbf{Q}^T \mathbf{B}_{\mathbf{z}} \mathbf{Q} = \mathbf{\Lambda}_m\)
where \(\mathbf{\Lambda}_m\) is a diagonal matrix. Then, we also note
that because of the properties of the determinant, if
\(\mathbf{F}\) is a solution to LDA-DR, so is \(\mathbf{F} \mathbf{Q}\).
Thus, we can assume that \(\mathbf{F}\) is the solution that
results in \(\mathbf{W}_{\mathbf{z}}^{-1} = \mathbf{I}_m\) and
\(\mathbf{B}_{\mathbf{z}} = \mathbf{\Lambda}_m\).</p>

<p>Using this, we have</p>

\[\left( \mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}} \right) \mathbf{F}  =
\mathbf{F} \mathbf{\Lambda}_m\]

<p>Multiplying the matrix \(\mathbf{F}\) on the right by
the diagonal matrix \(\mathbf{\Lambda}_m\) amounts to
scaling the columns of \(\mathbf{F}\). Thus,
the formula above tells us that when we multiply a column of
\(\mathbf{F}\) by \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\)
we get a scaled version of the same column. This means that
the columns of \(\mathbf{F}\) are eigenvectors of
\(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\),
a well known result (but whose proof is not easy to find).</p>

<p>However, note that we showed that the columns of \(\mathbf{F}\)
are eigenvectors of \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\),
but we did not specify which eigenvectors. The answer
is that the solution is the set of eigenvectors corresponding to
the largest \(m\) eigenvalues. This stems from the fact that
the trace of \(\mathbf{W}_{\mathbf{z}}^{-1} \mathbf{B}_{\mathbf{z}}\)
is the sum of the eigenvalues, and its eigenvalues are
the same as the eigenvalues of the selected eigenvectors
of \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\).
Thus, we maximize the trace by selecting the eigenvectors
of \(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\)
with the largest eigenvalues.
For a proof, see <a href="https://stats.stackexchange.com/a/661296/134438">this StackExchange answer</a>.</p>

<h3 id="lda-dr-features-make-w_z--i-and-b_z-diagonal">LDA-DR features make \(W_z = I\) and \(B_z\) diagonal</h3>

<p>It is worth stopping here that in the previous section it
was shown in passing that the eigenvectors of
\(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\) result
in a feature space where \(\mathbf{W}_{\mathbf{z}}\) is the
identity matrix and \(\mathbf{B}_{\mathbf{z}}\) is diagonal,
with the eigenvalues of the selected eigenvectors in the diagonal.
This is a very useful property. However, note that not all of the
infinite set of solutions to LDA-DR have this property, it is
specifically a property of the solution given by the eigenvectors of
\(\mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}}\), which is
the most commonly used one.</p>

<h3 id="lda-dr-features-for-j_2-are-the-same-as-for-j_1">LDA-DR features for \(J_2\) are the same as for \(J_1\)</h3>

<p>Next, we show that the solution for \(J_2(\mathbf{F})\) is the same as for
\(J_1(\mathbf{F})\). We start by taking the derivative of \(J_2(\mathbf{F})\):</p>

\[\frac{\partial J_2(\mathbf{F})}{\partial \mathbf{F}} =
-2 \mathbf{W}_{\mathbf{x}} \mathbf{F} (\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})^{-1} 
(\mathbf{F}^T \mathbf{B}_{\mathbf{x}} \mathbf{F})
(\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})^{-1} +
2 \mathbf{B}_{\mathbf{x}} \mathbf{F} (\mathbf{F}^T \mathbf{W}_{\mathbf{x}} \mathbf{F})^{-1}\]

\[=-2 \mathbf{W}_{\mathbf{x}} \mathbf{F} \mathbf{W}^{-1}_{\mathbf{z}}
\mathbf{B}_{\mathbf{z}} \mathbf{W}^{-1}_{\mathbf{z}} +
2 \mathbf{B}_{\mathbf{x}} \mathbf{F} \mathbf{W}^{-1}_{\mathbf{z}}\]

<p>We again set the derivative to zero to find the maximum, and rearranging,
we get that \(\mathbf{F}\) must satisfy the following condition:</p>

\[\left( \mathbf{W}_{\mathbf{x}}^{-1} \mathbf{B}_{\mathbf{x}} \right) \mathbf{F}  =
\mathbf{F}  \left( \mathbf{W}_{\mathbf{z}}^{-1} \mathbf{B}_{\mathbf{z}} \right)\]

<p>This is the same condition that we found for \(J_1(\mathbf{F})\), so
the solutions are the same.</p>

<h3 id="lda-dr-features-for-j_3-are-not-the-same-as-for-j_1">LDA-DR features for \(J_3\) are not the same as for \(J_1\)</h3>

<p>Finally, we note that the solution for \(J_3(\mathbf{F})\) is different.
This is immediately obvious from the fact that the filters obtained
with \(J_1\) and \(J_2\) are not necessarily orthogonal, while the
filters obtained with \(J_3\) are constrained to be orthogonal.
We note, however, that when \(m=1\) and we are only looking for a single
filters, the solution for \(J_3\) is the same as for \(J_1\) and \(J_2\).</p>

<p>For the objective \(J_3\) there is no closed form solution, 
and it is common to use an iterative method to find the solution.
Another relevant difference between \(J_3\) and \(J_1\)/\(J_2\) is
that the former is not invariant to linear transformations
of the data, while the latter are invariant.</p>

<h2 id="lda-dimensionality-reduction-does-not-necessarily-maximize-the-performance-of-the-lda-classifier">LDA dimensionality reduction does not necessarily maximize the performance of the LDA classifier</h2>

<p>Finally, we note that the LDA-DR features do not necessarily
maximize the performance of the LDA classifier, even if
the classes are homoscedatic Gaussians. This is usually noted
in textbooks, where it is said that the between-class to
within-class variance ratio is just a proxy for discriminability.
But because the two techniques are so closely related,
it is easy to overlook this fact.</p>

<p>This can be shown with a simple example, similar to the ones
typically used to illustrate how LDA-DR works. In this example
we have 4 classes in a 2D space. Along the x axis, we have
that the classes are separated into two pairs, where the classes in
each pair have almost complete overlap, but the two pairs are very
far apart. Along the y axis, we have that all the classes
are equally spaced, with good separation, but the distance between
the classes is smaller. All classes have spherical within-class
covariance. Let’s see the example data together with the
first (red) and second (blue) LDA features:</p>

<p><img src="/files/blog/LDA/lda.png" alt="" /></p>

<p>Because LDA-DR basically maximizes the pairwise squared
distances between the whitened class means
(see <a href="https://stats.stackexchange.com/a/660114/134438">this StackExchange answer</a> for a proof),
the first LDA feature captures the large distance
between the two pairs of classes. This direction is not very
discriminative, since each class will have close to chance
performance with respect to the overlapping class. The second
LDA feature will have smaller distances between classes, but
the classes will be more discriminable. Let’s visualize this
by plotting the Gaussian densities of each class as projected
onto the LDA features:</p>

<p><img src="/files/blog/LDA/lda1.png" alt="" /></p>

<p><img src="/files/blog/LDA/lda2.png" alt="" /></p>

<p>We see that the first LDA feature is not the most discriminative one.
Thus, this example shows that the LDA-DR features do not
necessarily maximize the performance of the LDA classifier,
even if the classes satisfy the assumptions of the LDA classifier.</p>

<h2 id="information-geometry-interpretation-of-lda">Information geometry interpretation of LDA</h2>

<p>The objective of LDA can also be interpreted in terms of
information geometry, which is the field that studies
manifolds of probability distributions. If you are
interested in learning more about this interpretation,
check out my preprint
<a href="https://arxiv.org/abs/2502.00168">“Supervised Quadratic Feature Analysis: An Information Geometry Approach to Dimensionality Reduction”</a>.</p>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Statistics" /><category term="Feature learning" /><category term="Supervised" /><category term="Dimensionality reduction" /><category term="Python" /><summary type="html"><![CDATA[Sometimes you think you understand a basic statistics problem. And then, you look closer and realize that you don’t understand it as well as you thought. This happened to me recently with Linear Discriminant Analysis (LDA). In this post, I discuss some interesting and lesser known aspects of LDA that I learned when diving deeper into this method, and that seem to often cause confusion.]]></summary></entry><entry><title type="html">Layperson’s Guide to the Science Reproducibility Crisis</title><link href="https://dherrera1911.github.io/posts/2025/03/reproducibility-crisis-for-layperson/" rel="alternate" type="text/html" title="Layperson’s Guide to the Science Reproducibility Crisis" /><published>2025-02-13T00:00:00-08:00</published><updated>2025-02-13T00:00:00-08:00</updated><id>https://dherrera1911.github.io/posts/2025/03/reproducibility-crisis-for-layman</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2025/03/reproducibility-crisis-for-layperson/"><![CDATA[<p>Science is largely responsible for the tremendous progress civilization has made over the last few centuries. It has also shaped how we see ourselves and our place in the universe. However, like any other human endeavor, science is not perfect. Recent years have seen a growing debate about the so-called “reproducibility crisis,” which refers to the inability to reproduce the results of many scientific studies. This is a serious issue that affects not only the scientific community but
also society as a whole. In this post, I provide an accessible overview of the
issue.</p>

<p>This post is based on an invited article that I wrote for a Uruguayan news
outlet on February 2019. The original article is in Spanish and can be found
<a href="https://www.uypress.net/Columnistas/Daniel-Herrera-uc93635">here</a>.</p>

<h2 id="reproducibility-crisis-and-science-culture">Reproducibility crisis and science culture</h2>

<p>Science advances through a mechanism in which different hypotheses are generated and then tested experimentally, gradually refining our understanding of the world and leading us closer to near-absolute truths (e.g., the universe is 13.8 billion years old, Earth is warming due to human activity, etc.). However, this process is not entirely objective, because it is carried out by people within a particular cultural framework—specifically, a scientific culture—that determines which methods of doing and communicating science are acceptable, which evidence is considered valid, and which topics are interesting or controversial.</p>

<p>Like any other culture, scientific culture is strongly influenced by the personal interests of its members (career and prestige), by institutions that may function poorly (such as universities, funding agencies, and scientific societies), by inertia that perpetuates unhelpful customs (“we’ve always done it this way”), and by historical contingencies. This matters because, although the scientific method has proven effective in the long run (over decades and centuries), if scientific culture promotes good practices, knowledge advances more quickly, public resources are used more efficiently, and the population gains more reliable scientific information.</p>

<p>This scientific culture is at the center of the current debate on what is called the “reproducibility crisis.” To understand the debate, it’s important to clarify what reproducibility means. In general, scientific publications present the results of experiments used to argue for or against a hypothesis. Science is an ongoing discussion among existing hypotheses, advancing on the basis of accumulated experimental evidence. But for this process to work, published results must be reproducible, meaning that repeating the experiments yields similar results. This makes sense: if you do an experiment to study the effect of a low-sugar diet on heart health, you would expect similar outcomes if you repeated it; otherwise, the original results would be of limited value. Although complete reproducibility of all scientific publications is not feasible, a growing number of scientists argue that the proportion of non-reproducible studies (those that yield different results when repeated) is much higher than acceptable, and that flaws in scientific culture are to blame. (It’s worth noting that some fields are more affected than others: an estimated 50% of psychology studies cannot be reproduced; biology is next in line for concern, while physics is mentioned far less often.)</p>

<h2 id="why-does-this-matter">Why does this matter?</h2>

<p>Knowing what the problem is, a natural question arises: why does this matter for society? Its importance is evident in three main areas. The most visible effect appears in popular science communication (although the media also share some of the blame). A key recent example of the reproducibility crisis is the work of Dr. Brian Wansink, who ran a lab at the prestigious Cornell University in the United States studying people’s eating behaviors. Far from being a scientist cloistered in his lab, Wansink was a major figure in popularizing ideas about the psychology of eating: he published several successful popular science books and frequently appeared on TV shows, in documentaries, and in the media. His work generated widely shared recommendations like using smaller plates to serve food or avoiding eating while watching TV. However, Wansink’s scientific career came to an abrupt end in 2018 when it was discovered that his publications were riddled with errors and negligence. As a result, the conclusions he had drawn (and which had been widely publicized) were not in fact supported by solid scientific evidence. Although Wansink’s case is extreme in how far he took these poor scientific practices, many of those same practices—albeit in less extreme forms—are common in various research areas.</p>

<p>Wansink’s story also illustrates another societal impact of reproducibility problems: wasted resources. Over its lifetime, his lab spent millions of dollars in taxpayer funding and relied on the work of many bright people to run experiments (which could have been a solid investment if done properly). These effects then multiply: other labs invest resources building on Wansink’s results, only to find them irreproducible, which increases confusion in the scientific literature. Many young researchers trained in his lab also missed out on learning how to conduct solid, reproducible research, perpetuating issues into the next generation of scientists. Finally, Wansink’s work was the foundation of large government programs (costing millions of dollars) that aimed to implement his findings to improve eating habits in U.S. schools. It’s likely these interventions will not work, and that the effort and resources could have made a positive impact if based on more robust evidence. Although few individual researchers reach Wansink’s level of social influence, the cumulative effect of many “gray-area” practices—far more common and culturally accepted—is exponentially greater.</p>

<p>A third effect of reproducibility problems is their impact on advances in medicine and technology. For example, it has been reported that a large percentage of findings in cancer biology cannot be replicated by other scientists or by companies developing drugs for the disease. This creates greater uncertainty in drug development, requiring more time and money (billions of dollars) to test potential drugs, instead of being able to focus on fewer, but more reliable, candidates. A more recent debate concerns a potential reproducibility problem in machine learning (a branch of artificial intelligence), an area experiencing explosive growth in both academia and industry. These examples raise serious questions about the human and economic costs of a lack of robustness in science.</p>

<h2 id="causes-for-the-reproducibility-crisis">Causes for the reproducibility crisis</h2>

<p>If the reproducibility problem is bad for society and for science, why does it happen? The causes are numerous and complex, but can be grouped into three main categories:</p>

<p>1) Gathering and interpreting data from complex systems (like living beings) is hard. There are many variables that can cause two repetitions of the same experiment to yield different results, either due to poor experimental design or random chance. Although there are many tools for experimental design and statistical analysis, in most scientists’ education these fields play a secondary or even tertiary role.</p>

<p>2) Scientists are often evaluated based on criteria that do not reward reproducible science but rather the sheer number of publications. Securing a publication that meets a journal’s minimal methodological requirements (which are not very high) is easier and faster than publishing a robust, reproducible study (which, for instance, might require larger sample sizes or multiple repetitions of an experiment). Consequently, it can be better for a researcher’s career to publish many less reliable papers than fewer but more trustworthy ones.</p>

<p>3) Another key factor in a scientist’s career—also assessed by funding agencies and universities—is how interesting the “story” behind the results appears to be (think of the appealing, intuitive stories Wansink told). Naturally, this can lead to a focus on narrative over empirical strength, such as downplaying (or burying) contrary evidence and overstating supportive data. Over time, these practices can make a published story look rock-solid when it’s really a product of selective evidence and exaggeration.</p>

<h2 id="what-can-be-done">What can be done?</h2>

<p>Fortunately, there is reason for optimism. The international scientific community is increasingly aware of these issues, and there are new initiatives underway to address the three main causes mentioned above. Although it is a difficult task—like any cultural change—some fields are already showing important shifts. Examples include introducing new standards for statistical analysis and experimental design, revising methods for evaluating researchers, and requiring greater transparency (e.g., making all generated data publicly accessible), which allows for better error detection. Universities, funding agencies, societies, and scientific journals must support these initiatives against the inertia in academia to improve science’s overall social impact. It remains to be seen how long it will take for these efforts to be integrated into the culture, and whether they will succeed in boosting reproducibility in the scientific literature.</p>

<p>Finally, it helps to end with a note on how science fundamentally works. Reaching a scientific truth is typically a continuous process over many years. At any given time, multiple hypotheses compete, and individual studies provide evidence for or against them, but rarely is a single study decisive. As evidence accumulates, the scientific community reaches a consensus on certain aspects of the world and then builds on those conclusions. This is important because it means that science does not depend on each individual study being infallible; rather, it depends on weighing each study according to its value and robustness. The reproducibility problem makes it more difficult to use published results to advance knowledge, but it does not stop science from functioning as it always has. What does this mean for people who want to stay informed about scientific developments? It means that no single study can tell us whether eating chocolate or having a daily glass of wine is good or bad for our health, whether a certain educational method is best for our children, or whether the key to happiness lies in getting an hour of sun every day or forcing ourselves to smile. Collections of studies—such as science-based books—are generally a better source of reliable information, though as in the case of Wansink’s books, there can still be pitfalls. We simply need to maintain a healthy level of skepticism about what we read, recognize the difference between scientific consensus and individual findings claiming to have found definitive answers, and choose trustworthy sources that prioritize the robustness of the evidence over “telling a good story.”</p>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Science" /><category term="Meta-science" /><category term="Reproducibility" /><category term="Science Communication" /><summary type="html"><![CDATA[Science is largely responsible for the tremendous progress civilization has made over the last few centuries. It has also shaped how we see ourselves and our place in the universe. However, like any other human endeavor, science is not perfect. Recent years have seen a growing debate about the so-called “reproducibility crisis,” which refers to the inability to reproduce the results of many scientific studies. This is a serious issue that affects not only the scientific community but also society as a whole. In this post, I provide an accessible overview of the issue.]]></summary></entry><entry><title type="html">Covariance shrinkage for begginers with Python implementation</title><link href="https://dherrera1911.github.io/posts/2024/09/covariance-shrinkage-for-begginers/" rel="alternate" type="text/html" title="Covariance shrinkage for begginers with Python implementation" /><published>2024-12-03T00:00:00-08:00</published><updated>2024-12-03T00:00:00-08:00</updated><id>https://dherrera1911.github.io/posts/2024/09/covariance-shrinkage</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2024/09/covariance-shrinkage-for-begginers/"><![CDATA[<p>Covariance matrices are one of the most important objects in
statistics and machine learning, essential to many algorithms.
But estimating covariance matrices can be difficult,
especially in high-dimensional settings. In this post we
introduce covariance shrinkage, a technique
to improve covariance matrix estimation. We also
provide a PyTorch implementation of a popular shrinkage
technique, the Oracle Approximating Shrinkage (OAS) estimator.</p>

<h2 id="introduction">Introduction</h2>

<p>One issue with estimating the true covariance matrix
\(\Sigma\) for a given population or random variable is that,
if \(p\) is the number of dimensions, \(\Sigma\) has
\(p \times p\) entries, of which \((p+1)p/2\)
are independent parameters that need to be estimated (this number comes
from considering the symmetry of covariance matrices<sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">1</a></sup>).
This means that the number of parameters
needed to estimate the covariance matrix grows fast with the
number of dimensions. That is, covariance estimation suffers
from the curse of dimensionality.</p>

<p>An interesting and challenging statistical
problem is how to estimate a covariance matrix when the number of
observations \(n\) is not large compared to the number of dimensions \(p\).</p>

<p>The simplest way to estimate the covariance
matrix is to use the sample covariance \(S\). Given a dataset of
\(n\) observations of \(p\)-dimensional
random variable \(X \in \mathbb{R}^p\), with the observations labeled
as \(X_1, X_2, \ldots, X_n\), the sample covariance matrix is
defined as</p>

\[S = \frac{1}{n-1} \sum_{i=1}^n (X_i - \bar{X})(X_i - \bar{X})^T\]

<p>where \(\bar{X}\) is the sample mean. \(S\) is an unbiased
estimator of the true population covariance matrix, which means
that the expected value of \(S\) is the true covariance matrix.</p>

<p>However, the sample covariance has some drawbacks as an
estimator. Mainly, \(S\) is unstable when \(n\) is small
compared to \(p\), meaning that it can have large errors
with respect to the true covariance \(\Sigma\).</p>

<p>Let’s visualize this variability with some simulations.
We first define a diagonal covariance matrix \(\Sigma\). Then
we take 20 samples from a 10-dimensional Gaussian distribution
that has \(\Sigma\) as its true covariance matrix. Then we
compute the sample covariance matrix \(S\) for these
observations. We repeat this process 5 times and plot the
resulting sample covariance matrices.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">from</span> <span class="nn">torch.distributions</span> <span class="kn">import</span> <span class="n">MultivariateNormal</span>
<span class="kn">import</span> <span class="nn">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>

<span class="n">torch</span><span class="p">.</span><span class="n">manual_seed</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>

<span class="c1"># Simulation parameters
</span><span class="n">n_dim</span> <span class="o">=</span> <span class="mi">10</span>      <span class="c1"># Number of dimensions of the random variable
</span><span class="n">n_samples</span> <span class="o">=</span> <span class="mi">20</span>  <span class="c1"># Number of samples to use
</span><span class="n">n_reps</span> <span class="o">=</span> <span class="mi">5</span>      <span class="c1"># Number of sample covariance matrices to compute
</span>
<span class="c1"># True covariance matrix
</span><span class="n">cov_pop</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linspace</span><span class="p">(</span><span class="n">start</span><span class="o">=</span><span class="mf">0.1</span><span class="p">,</span> <span class="n">end</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">steps</span><span class="o">=</span><span class="n">n_dim</span><span class="p">)</span>
<span class="n">cov_pop</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">diag</span><span class="p">(</span><span class="n">cov_pop</span><span class="p">)</span>

<span class="c1"># Compute sample covariance matrices
</span><span class="n">covs_sample</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_reps</span><span class="p">):</span>
    <span class="n">X</span> <span class="o">=</span> <span class="n">MultivariateNormal</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_dim</span><span class="p">),</span> <span class="n">cov_pop</span><span class="p">).</span><span class="n">sample</span><span class="p">((</span><span class="n">n_samples</span><span class="p">,))</span>
    <span class="n">covs_sample</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">cov</span><span class="p">(</span><span class="n">X</span><span class="p">.</span><span class="n">T</span><span class="p">))</span>

<span class="c1"># Plot sample covariance matrices
</span><span class="n">max_val</span> <span class="o">=</span> <span class="mf">1.2</span>
<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">n_reps</span><span class="o">+</span><span class="mi">1</span><span class="p">,</span> <span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">15</span><span class="p">,</span> <span class="mi">3</span><span class="p">))</span>

<span class="c1"># Plot and store the image objects
</span><span class="n">im</span> <span class="o">=</span> <span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">cov_pop</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="s">"Population cov"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>
<span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">ax</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">:]):</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">imshow</span><span class="p">(</span><span class="n">covs_sample</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_title</span><span class="p">(</span><span class="sa">f</span><span class="s">"Sample cov </span><span class="si">{</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>

<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>

<span class="c1"># Adjust the subplots to make room for the color bar
</span><span class="n">fig</span><span class="p">.</span><span class="n">subplots_adjust</span><span class="p">(</span><span class="n">right</span><span class="o">=</span><span class="mf">0.85</span><span class="p">)</span>

<span class="c1"># Add an axes for the color bar on the right
</span><span class="n">cbar_ax</span> <span class="o">=</span> <span class="n">fig</span><span class="p">.</span><span class="n">add_axes</span><span class="p">([</span><span class="mf">0.87</span><span class="p">,</span> <span class="mf">0.25</span><span class="p">,</span> <span class="mf">0.02</span><span class="p">,</span> <span class="mf">0.5</span><span class="p">])</span>

<span class="c1"># Add the color bar to the figure
</span><span class="n">fig</span><span class="p">.</span><span class="n">colorbar</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="n">cax</span><span class="o">=</span><span class="n">cbar_ax</span><span class="p">)</span>

<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>
<p><img src="/files/blog/shrinkage/cov_var1.png" alt="" /></p>

<p>We see that for each different draw of 20 samples the corresponding
\(S\) changes considerably, because of the high variability in
the sample covariance matrix.</p>

<p>Another problem with \(S\) as an estimator, is that many applications require
inverting the covariance matrix. When \(p &gt; n\), the sample covariance
is not even invertible, making \(S\) unsuitable for some applications.
But even if \(p&lt;n\) and \(S\) is invertible, the high estimation error in
\(S\) can be highly amplified when inverting the matrix.</p>

<p>Thus, the sample covariance matrix might not be the best
estimator for some applications. How can we obtain better
estimates of the covariance matrix, though? One alternative is to use
shrinkage.</p>

<h2 id="shrinkage-estimators">Shrinkage estimators</h2>

<p>Shrinkage is an essential idea in statistics. Intuitively,
if we observe an extreme value in a random sample, it is likely that
noise contributed to the extremeness of the value. In other words,
we can expect that extreme observed values are not representative of
the true underlying parameter. Shrinkage is a statistical procedure to
account for this phenomenon when estimating the underlying parameters,
by pulling the more extreme values in an observed sample towards the middle.
The more extreme the value, the more we shrink it towards the middle. This
procedure reduces the variance of the estimates, at the cost of introducing
some bias, a classical example of the bias-variance trade-off in statistics.</p>

<p>Shrinkage estimators of the covariance incorporate this idea into
covariance estimation. Like in the example above, the idea consists
of shrinking the observed sample covariance towards a “middle” or
“target” value. This will reduce the variance
of the estimates, at the cost of introducing some bias.</p>

<p>What is a good target matrix to shrink the sample covariance towards?
A popular target is the following diagonal matrix, which
is an isotropic estimator of the covariance matrix:</p>

\[\hat{F} = \frac{1}{p} \text{tr}(S) I\]

<p>where \(I\) is the identity matrix. We can think of \(\hat{F}\)
as an estimator of \(\Sigma\) that has low variance but
possibly high bias.</p>

<p>The next question to ask ourselves is, how do we “shrink” the sample
covariance towards the target matrix \(\hat{F}\)? Linear shrinkage
estimators do this by taking a linear combination of the sample
covariance matrix \(S\) and the target matrix \(\hat{F}\),
with a parameter \(\rho\) that controls the amount of shrinkage:</p>

\[\hat{\Sigma} = (1-\rho) S + \rho \hat{F}\]

<p>When \(\rho = 0\), the estimate is the sample covariance matrix.</p>

<p>The last question we need to ask ourselves is how to find a value
of \(\rho\) that results in a good estimate. This is a challenging
problem, as the optimal value of \(\rho\) depends on the true
covariance matrix, which is unknown. We turn to this question
in the next section.</p>

<h2 id="oracle-approximating-shrinkage-estimator-with-implementation">Oracle Approximating Shrinkage estimator (with implementation)</h2>

<p>A typical way to find a good value of \(\rho\) is to start
by assuming that the true covariance matrix \(\Sigma\) is known,
and choosing an estimation criterion to minimize. For example,
one common criterion to optimize is the mean squared error (MSE)
between the estimated covariance matrix \(\hat{\Sigma}\) and the true
covariance matrix \(\Sigma\):</p>

\[\min_{\rho} \mathbb{E} \left[ \left\| \hat{\Sigma} - \Sigma \right\|_F^2 \right]\]

<p>where \(\| \cdot \|_F\) is the Frobenius norm<sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">2</a></sup>. Under a known
\(\Sigma\), it is often possible to find a formula for what
the optimal value of \(\rho\) is, which can be denoted as
the oracle value of \(\rho\).</p>

<p>In practice, however, we do not know the true covariance matrix
\(\Sigma\). Thus, the challenge is to find a way to approximate
the unknown optimal value of \(\rho\) when \(\Sigma\) is unknown.</p>

<p>This is what <a href="https://ieeexplore.ieee.org/document/5484583">Oracle Approximating Shrinkage (OAS)</a>
does: it proposes a formula to approximate the oracle value of \(\rho\)
that minimizes the MSE, under the assumption that the data is
Gaussian distributed. This method performs particularly well
when the number of observations \(n\) is small compared to the number
of dimensions \(p\). The OAS formula for \(\rho\) is as follows:</p>

\[\hat{\rho}_{OAS} = \frac{(1-2p)\mathrm{Tr}(S^2) + \mathrm{Tr}^2(S)}
{(n+1-2/p) (\mathrm{Tr}(S^2) - \mathrm{Tr}^2(S)/p}\]

<p>where we cap the result at 1 (if the value of the formula
above is larger than one, we set \(\hat{\rho}_{OAS}=1\)).</p>

<p>Let’s implement the OAS estimator in PyTorch:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">isotropic_estimator</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">):</span>
    <span class="s">"""Isotropic covariance estimate with same trace as sample.
    
    Arguments:
    ---------- 
    sample_covariance : torch.Tensor
        Sample covariance matrix.
    """</span>
    <span class="n">n_dim</span> <span class="o">=</span> <span class="n">sample_covariance</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">torch</span><span class="p">.</span><span class="n">eye</span><span class="p">(</span><span class="n">n_dim</span><span class="p">)</span> <span class="o">*</span> <span class="n">torch</span><span class="p">.</span><span class="n">trace</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">)</span> <span class="o">/</span> <span class="n">n_dim</span>

<span class="k">def</span> <span class="nf">oas_shrinkage</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">,</span> <span class="n">n_samples</span><span class="p">):</span>
    <span class="s">"""Get OAS shrinkage parameter.
    
    Arguments:
    ----------
    sample_covariance : torch.Tensor
        Sample covariance matrix.
    """</span>
    <span class="n">n_dim</span> <span class="o">=</span> <span class="n">sample_covariance</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="n">tr_cov</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">trace</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">)</span>
    <span class="n">tr_prod</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">sum</span><span class="p">(</span><span class="n">sample_covariance</span> <span class="o">**</span> <span class="mi">2</span><span class="p">)</span>
    <span class="n">shrinkage</span> <span class="o">=</span> <span class="p">(</span>
      <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="mi">2</span> <span class="o">/</span> <span class="n">n_dim</span><span class="p">)</span> <span class="o">*</span> <span class="n">tr_prod</span> <span class="o">+</span> <span class="n">tr_cov</span> <span class="o">**</span> <span class="mi">2</span>
    <span class="p">)</span> <span class="o">/</span> <span class="p">(</span>
      <span class="p">(</span><span class="n">n_samples</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">-</span> <span class="mi">2</span> <span class="o">/</span> <span class="n">n_dim</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="n">tr_prod</span> <span class="o">-</span> <span class="n">tr_cov</span> <span class="o">**</span> <span class="mi">2</span> <span class="o">/</span> <span class="n">n_dim</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="n">shrinkage</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">shrinkage</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">shrinkage</span>

<span class="k">def</span> <span class="nf">oas_estimator</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">assume_centered</span><span class="o">=</span><span class="bp">False</span><span class="p">):</span>
    <span class="s">"""Oracle Approximating Shrinkage (OAS) covariance estimate.

    Arguments:
    ----------
    X : torch.Tensor
        Data matrix with shape (n_samples, n_features).
    """</span>
    <span class="n">n_samples</span> <span class="o">=</span> <span class="n">X</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>

    <span class="c1"># Compute sample covariance
</span>    <span class="k">if</span> <span class="ow">not</span> <span class="n">assume_centered</span><span class="p">:</span>
        <span class="n">sample_covariance</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">cov</span><span class="p">(</span><span class="n">X</span><span class="p">.</span><span class="n">T</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">sample_covariance</span> <span class="o">=</span> <span class="n">X</span><span class="p">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">X</span> <span class="o">/</span> <span class="n">n_samples</span>

    <span class="c1"># Compute isotropic estimator F
</span>    <span class="n">isotropic</span> <span class="o">=</span> <span class="n">isotropic_estimator</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">)</span>

    <span class="c1"># Compute OAS shrinkage parameter
</span>    <span class="n">shrinkage</span> <span class="o">=</span> <span class="n">oas_shrinkage</span><span class="p">(</span><span class="n">sample_covariance</span><span class="p">,</span> <span class="n">n_samples</span><span class="p">)</span>

    <span class="c1"># Compute OAS shrinkage covariance estimate
</span>    <span class="n">oas_estimate</span> <span class="o">=</span> <span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">shrinkage</span><span class="p">)</span> <span class="o">*</span> <span class="n">sample_covariance</span> <span class="o">+</span> <span class="n">shrinkage</span> <span class="o">*</span> <span class="n">isotropic</span>
    <span class="k">return</span> <span class="n">oas_estimate</span>
</code></pre></div></div>

<p>Let’s now test the OAS estimator with the same simulations as before.
We again sample 20 observations from a 10-dimensional Gaussian,
and we compute both the sample covariance matrix \(S\) and the
OAS estimator \(\hat{\Sigma}\) for the data. We repeat this
process 5 times, and plot both the covariance estimates, and
compute the MSE between the true covariance matrix and both
estimators.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">n_dim</span> <span class="o">=</span> <span class="mi">10</span>
<span class="n">n_samples</span> <span class="o">=</span> <span class="mi">20</span>
<span class="n">n_reps</span> <span class="o">=</span> <span class="mi">5</span>

<span class="c1"># Compute sample covariance matrices
</span><span class="n">covs_sample</span> <span class="o">=</span> <span class="p">[]</span>
<span class="n">covs_oas</span> <span class="o">=</span> <span class="p">[]</span>
<span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_reps</span><span class="p">):</span>
    <span class="n">X</span> <span class="o">=</span> <span class="n">MultivariateNormal</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_dim</span><span class="p">),</span> <span class="n">cov_pop</span><span class="p">).</span><span class="n">sample</span><span class="p">((</span><span class="n">n_samples</span><span class="p">,))</span>
    <span class="n">covs_sample</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">cov</span><span class="p">(</span><span class="n">X</span><span class="p">.</span><span class="n">T</span><span class="p">))</span>
    <span class="n">covs_oas</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">oas_estimator</span><span class="p">(</span><span class="n">X</span><span class="p">))</span>

<span class="c1"># Plot sample covariance matrices
</span><span class="n">max_val</span> <span class="o">=</span> <span class="mf">1.2</span>
<span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="n">n_reps</span><span class="o">+</span><span class="mi">1</span><span class="p">,</span> <span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">15</span><span class="p">,</span> <span class="mi">7</span><span class="p">))</span>
<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">cov_pop</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
<span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="s">"Population cov"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>
<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">cov_pop</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
<span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">0</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="s">"Population cov"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>

<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_reps</span><span class="p">):</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="o">+</span><span class="n">i</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">covs_sample</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="mi">1</span><span class="o">+</span><span class="n">i</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="sa">f</span><span class="s">"Sample cov </span><span class="si">{</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="o">+</span><span class="n">i</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">covs_oas</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'seismic'</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=-</span><span class="n">max_val</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">max_val</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span><span class="mi">1</span><span class="o">+</span><span class="n">i</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="sa">f</span><span class="s">"OAS cov </span><span class="si">{</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">18</span><span class="p">)</span>

<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>

<span class="c1"># Compute mean squared error
</span><span class="n">mse_sample</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">stack</span><span class="p">([</span><span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">cov_pop</span> <span class="o">-</span> <span class="n">cov</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span> <span class="k">for</span> <span class="n">cov</span> <span class="ow">in</span> <span class="n">covs_sample</span><span class="p">]).</span><span class="n">mean</span><span class="p">()</span>
<span class="n">mse_oas</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">stack</span><span class="p">([</span><span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">cov_pop</span> <span class="o">-</span> <span class="n">cov</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span> <span class="k">for</span> <span class="n">cov</span> <span class="ow">in</span> <span class="n">covs_oas</span><span class="p">]).</span><span class="n">mean</span><span class="p">()</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"MSE sample covariance: </span><span class="si">{</span><span class="n">mse_sample</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"MSE OAS covariance: </span><span class="si">{</span><span class="n">mse_oas</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p><img src="/files/blog/shrinkage/cov_oas1.png" alt="" /></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>MSE sample covariance: 1.9461839199066162
MSE OAS covariance: 0.659830629825592
</code></pre></div></div>

<p>We see that:</p>
<ul>
  <li>OAS estimator provides a more stable estimate than \(S\)</li>
  <li>OAS estimator diagonal elements are more biased than \(S\)</li>
  <li>OAS estimator has lower MSE than \(S\)</li>
</ul>

<h2 id="linear-discriminant-analysis-with-shrinkage">Linear Discriminant Analysis with shrinkage</h2>

<p>Let’s next compare the OAS estimator to the sample covariance
estimator in a practical setting. For this, we will
use Linear Discriminant Analysis (LDA) as applied to the MNIST dataset.</p>

<h3 id="linear-discriminant-analysis">Linear Discriminant Analysis</h3>

<p>LDA is a standard technique for
dimensionality reduction and classification. In a labeled dataset,
LDA learns the filters that maximize the separation between classes,
while minimizing the within-class variability. This goal can be
mathematically formulated as follows:</p>

<ul>
  <li>In a dataset with \(C\) classes, the between-class scatter matrix
\(S_B = \frac{1}{C}\sum_{i=1}^C (\mu_i - \mu)(\mu_i - \mu)^T\)
(i.e. covariance of the class means centered around the global mean)
indicates how the class means are spread out in the data space. In this
formula, \(\mu_i\) is the mean of class \(i\), and \(\mu\) is the global mean.</li>
  <li>The within-class scatter matrix
\(S_W = \frac{1}{N} \sum_{i=1}^C \sum_{x \in X_i} (x - \mu_i)(x - \mu_i)^T\),
where \(N\) is the total number of samples, and \(X_i\) is the residual
covariance around the class means, i.e. the within-class variability.</li>
  <li>If we project the data along vector \(w\), the variance between classes
will be given by \(w^T S_B w\), and the variance within classes will
be given by \(w^T S_W w\)</li>
  <li>Thus, the filters \(w\) that maximize between-class separation while
minimizing within-class variance are the directions that maximize
the following ratio:</li>
</ul>

\[\frac{w^T S_B w}{w^T S_W w}\]

<p>It turns out that the directions \(w\) that maximize the ratio above
are the eigenvectors of the matrix \(S_W^{-1} S_B\)
(see <a href="https://en.wikipedia.org/wiki/Linear_discriminant_analysis#Multiclass_LDA">here</a>).
Thus, we can find the LDA filters by computing the two scatter matrices,
inverting \(S_W\), and computing the eigenvectors of the product \(S_W^{-1} S_B\). 
LDA is also a linear classifier, which uses these projection to
classify new data points based on how close they are to the class means.</p>

<p>The quality of the LDA filters depends on the quality of the
estimates of \(S_W\) and \(S_B\). That’s why LDA is a good
application to compare covariance estimators: the quality 
of the LDA filters (which we can measure by the classification
accuracy) can be used as a proxy for the quality of the covariance
estimates.</p>

<h3 id="applying-lda-to-mnist">Applying LDA to MNIST</h3>

<p>Let’s first load the MNIST dataset using the <code class="language-plaintext highlighter-rouge">torchvision</code> package.</p>

<p>Note that we modify the dataset in two ways to better suit our example. 
First, we subsample the number of images, keeping only 2000 images so that
the number of observations \(n\) is close to the number of dimensions \(p\).
Second, we remove from the learning procedure some pixels that have zero
variance (i.e. they are constant across all images) to avoid
singular covariance matrices.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torchvision</span>

<span class="c1"># Download and load training and test datasets
</span><span class="n">trainset</span> <span class="o">=</span> <span class="n">torchvision</span><span class="p">.</span><span class="n">datasets</span><span class="p">.</span><span class="n">MNIST</span><span class="p">(</span><span class="n">root</span><span class="o">=</span><span class="s">'./data'</span><span class="p">,</span> <span class="n">train</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">download</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">testset</span> <span class="o">=</span> <span class="n">torchvision</span><span class="p">.</span><span class="n">datasets</span><span class="p">.</span><span class="n">MNIST</span><span class="p">(</span><span class="n">root</span><span class="o">=</span><span class="s">'./data'</span><span class="p">,</span> <span class="n">train</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">download</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="c1"># Reshape images into vectors
</span><span class="n">n_samples</span><span class="p">,</span> <span class="n">n_row</span><span class="p">,</span> <span class="n">n_col</span> <span class="o">=</span> <span class="n">trainset</span><span class="p">.</span><span class="n">data</span><span class="p">.</span><span class="n">shape</span>
<span class="n">n_dim</span> <span class="o">=</span> <span class="n">trainset</span><span class="p">.</span><span class="n">data</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">numel</span><span class="p">()</span>
<span class="n">x_train</span> <span class="o">=</span> <span class="n">trainset</span><span class="p">.</span><span class="n">data</span><span class="p">.</span><span class="n">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">n_dim</span><span class="p">).</span><span class="nb">float</span><span class="p">()</span>
<span class="n">y_train</span> <span class="o">=</span> <span class="n">trainset</span><span class="p">.</span><span class="n">targets</span>
<span class="n">x_test</span> <span class="o">=</span> <span class="n">testset</span><span class="p">.</span><span class="n">data</span><span class="p">.</span><span class="n">reshape</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="n">n_dim</span><span class="p">).</span><span class="nb">float</span><span class="p">()</span>
<span class="n">y_test</span> <span class="o">=</span> <span class="n">testset</span><span class="p">.</span><span class="n">targets</span>

<span class="c1"># Subsample data
</span><span class="n">N_SUBSAMPLE</span> <span class="o">=</span> <span class="mi">2000</span> <span class="c1"># Number of images to keep
</span><span class="n">rand_idx</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">randperm</span><span class="p">(</span><span class="n">x_train</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>
<span class="n">rand_idx</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">sort</span><span class="p">(</span><span class="n">rand_idx</span><span class="p">[:</span><span class="n">N_SUBSAMPLE</span><span class="p">]).</span><span class="n">values</span>
<span class="n">x_train</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">[</span><span class="n">rand_idx</span><span class="p">]</span>
<span class="n">y_train</span> <span class="o">=</span> <span class="n">y_train</span><span class="p">[</span><span class="n">rand_idx</span><span class="p">]</span>
<span class="n">x_train</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">[:</span><span class="n">N_SUBSAMPLE</span><span class="p">]</span>
<span class="n">y_train</span> <span class="o">=</span> <span class="n">y_train</span><span class="p">[:</span><span class="n">N_SUBSAMPLE</span><span class="p">]</span>

<span class="c1"># Mask pixels with zero variance
</span><span class="n">mask</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">.</span><span class="n">std</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span>
<span class="n">x_train</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">[:,</span> <span class="n">mask</span><span class="p">]</span>
<span class="n">x_test</span> <span class="o">=</span> <span class="n">x_test</span><span class="p">[:,</span> <span class="n">mask</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">unmask_image</span><span class="p">(</span><span class="n">image</span><span class="p">):</span>
    <span class="s">"""Function to return data vector to original shape."""</span>
    <span class="n">unmasked</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_dim</span><span class="p">)</span>
    <span class="n">unmasked</span><span class="p">[</span><span class="n">mask</span><span class="p">]</span> <span class="o">=</span> <span class="n">image</span>
    <span class="k">return</span> <span class="n">unmasked</span>

<span class="c1"># Scale data and subtract global mean
</span><span class="k">def</span> <span class="nf">scale_and_center</span><span class="p">(</span><span class="n">x_train</span><span class="p">,</span> <span class="n">x_test</span><span class="p">):</span>
    <span class="n">std</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">.</span><span class="n">std</span><span class="p">()</span>
    <span class="n">x_train</span> <span class="o">=</span> <span class="n">x_train</span> <span class="o">/</span> <span class="n">std</span>
    <span class="n">x_test</span> <span class="o">=</span> <span class="n">x_test</span> <span class="o">/</span> <span class="n">std</span>
    <span class="n">global_mean</span> <span class="o">=</span> <span class="n">x_train</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">keepdims</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">x_train</span> <span class="o">=</span> <span class="n">x_train</span> <span class="o">-</span> <span class="n">global_mean</span>
    <span class="n">x_test</span> <span class="o">=</span> <span class="n">x_test</span> <span class="o">-</span> <span class="n">global_mean</span>
    <span class="k">return</span> <span class="n">x_train</span><span class="p">,</span> <span class="n">x_test</span>

<span class="c1"># Scale data and subtract global mean
</span><span class="n">x_train</span><span class="p">,</span> <span class="n">x_test</span> <span class="o">=</span> <span class="n">scale_and_center</span><span class="p">(</span><span class="n">x_train</span><span class="p">,</span> <span class="n">x_test</span><span class="p">)</span>

<span class="c1"># Plot some images
</span><span class="n">names</span> <span class="o">=</span> <span class="n">y_train</span><span class="p">.</span><span class="n">unique</span><span class="p">().</span><span class="n">tolist</span><span class="p">()</span>
<span class="n">n_classes</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">y_train</span><span class="p">.</span><span class="n">unique</span><span class="p">())</span>
<span class="n">fig</span><span class="p">,</span> <span class="n">ax</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="n">n_classes</span><span class="p">,</span> <span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="mi">2</span><span class="p">))</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_classes</span><span class="p">):</span>
    <span class="n">ax</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span>
      <span class="n">unmask_image</span><span class="p">(</span><span class="n">x_train</span><span class="p">[</span><span class="n">y_train</span> <span class="o">==</span> <span class="n">i</span><span class="p">][</span><span class="mi">0</span><span class="p">]).</span><span class="n">reshape</span><span class="p">(</span><span class="n">n_row</span><span class="p">,</span> <span class="n">n_col</span><span class="p">),</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'gray'</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">axis</span><span class="p">(</span><span class="s">'off'</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="n">names</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">fontsize</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/shrinkage/mnist.png" alt="" /></p>

<p>Next, let’s compute the scatter matrices \(S_W\) and \(S_B\). Matrix \(S_W\)
is estimated both using the sample covariance and the OAS estimator.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Compute the class means
</span><span class="n">class_means</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">stack</span><span class="p">([</span><span class="n">x_train</span><span class="p">[</span><span class="n">y_train</span> <span class="o">==</span> <span class="n">i</span><span class="p">].</span><span class="n">mean</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_classes</span><span class="p">)])</span>
<span class="n">mu</span> <span class="o">=</span> <span class="n">class_means</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">keepdim</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="c1"># Compute between-class scatter matrix
</span><span class="n">between_class</span> <span class="o">=</span> <span class="p">(</span><span class="n">class_means</span> <span class="o">-</span> <span class="n">mu</span><span class="p">).</span><span class="n">T</span> <span class="o">@</span> <span class="p">(</span><span class="n">class_means</span> <span class="o">-</span> <span class="n">mu</span><span class="p">)</span> <span class="o">/</span> <span class="n">n_classes</span>

<span class="c1"># Compute the within-class scatter matrix
</span><span class="n">x_train_centered</span> <span class="o">=</span> <span class="n">x_train</span> <span class="o">-</span> <span class="n">class_means</span><span class="p">[</span><span class="n">y_train</span><span class="p">]</span>
<span class="n">within_class_sample</span> <span class="o">=</span> <span class="n">x_train_centered</span><span class="p">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">x_train_centered</span> <span class="o">/</span> <span class="n">N_SUBSAMPLE</span>
<span class="n">within_class_oas</span> <span class="o">=</span> <span class="n">oas_estimator</span><span class="p">(</span><span class="n">x_train_centered</span><span class="p">,</span> <span class="n">assume_centered</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div></div>

<p>Then, we compute the LDA filters obtained from each covariance matrix. We
add a small value to the diagonal of \(S_W\) for numerical stability:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Get LDA filters
</span><span class="n">n_filters</span> <span class="o">=</span> <span class="n">n_classes</span> <span class="o">-</span> <span class="mi">1</span>
<span class="k">def</span> <span class="nf">get_lda_filters</span><span class="p">(</span><span class="n">between_class</span><span class="p">,</span> <span class="n">within_class</span><span class="p">):</span>
    <span class="n">within_class_inv</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">inv</span><span class="p">(</span><span class="n">within_class</span><span class="p">)</span>
    <span class="n">lda_mat</span> <span class="o">=</span> <span class="n">within_class_inv</span> <span class="o">@</span> <span class="n">between_class</span>
    <span class="n">eigvals</span><span class="p">,</span> <span class="n">eigvecs</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">eigh</span><span class="p">(</span><span class="n">lda_mat</span><span class="p">)</span>
    <span class="n">filters</span> <span class="o">=</span> <span class="n">eigvecs</span><span class="p">[:,</span> <span class="o">-</span><span class="n">n_filters</span><span class="p">:]</span>
    <span class="k">return</span> <span class="n">filters</span><span class="p">.</span><span class="n">T</span>

<span class="c1"># Get the LDA projections
</span><span class="n">small_reg</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">eye</span><span class="p">(</span><span class="n">within_class_sample</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span> <span class="o">*</span> <span class="mf">1e-7</span>
<span class="n">lda_filters_sample</span> <span class="o">=</span> <span class="n">get_lda_filters</span><span class="p">(</span><span class="n">between_class</span><span class="p">,</span> <span class="n">within_class_sample</span> <span class="o">+</span> <span class="n">small_reg</span><span class="p">)</span>
<span class="n">lda_filters_oas</span> <span class="o">=</span> <span class="n">get_lda_filters</span><span class="p">(</span><span class="n">between_class</span><span class="p">,</span> <span class="n">within_class_oas</span> <span class="o">+</span> <span class="n">small_reg</span><span class="p">)</span>
</code></pre></div></div>

<p>Let’s now plot the LDA filters obtained with both covariance estimators:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Plot both LDA filters
</span><span class="n">fig</span><span class="p">,</span> <span class="n">axs</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">12</span><span class="p">,</span> <span class="mi">4</span><span class="p">))</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">9</span><span class="p">):</span>
    <span class="n">sample_filter_im</span> <span class="o">=</span> <span class="n">unmask_image</span><span class="p">(</span><span class="n">lda_filters_sample</span><span class="p">[</span><span class="n">i</span><span class="p">]).</span><span class="n">reshape</span><span class="p">(</span><span class="n">n_row</span><span class="p">,</span> <span class="n">n_col</span><span class="p">)</span>
    <span class="n">oas_filter_im</span> <span class="o">=</span> <span class="n">unmask_image</span><span class="p">(</span><span class="n">lda_filters_oas</span><span class="p">[</span><span class="n">i</span><span class="p">]).</span><span class="n">reshape</span><span class="p">(</span><span class="n">n_row</span><span class="p">,</span> <span class="n">n_col</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="n">i</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">sample_filter_im</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'gray'</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="n">i</span><span class="p">].</span><span class="n">axis</span><span class="p">(</span><span class="s">'off'</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="n">i</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">oas_filter_im</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">'gray'</span><span class="p">)</span>
    <span class="n">axs</span><span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="n">i</span><span class="p">].</span><span class="n">axis</span><span class="p">(</span><span class="s">'off'</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/shrinkage/lda_filters.png" alt="" /></p>

<p>We see that the filters obtained with the sample covariance estimator seem 
unstable, in that they put most of their weight into a few pixels. The
OAS filters are noisy, as we could expect from the small number of samples
used, but they are smoother and better distributed across the image.</p>

<p>Finally, we can compute the classification accuracy of the LDA filters</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">sklearn.discriminant_analysis</span> <span class="kn">import</span> <span class="n">LinearDiscriminantAnalysis</span>
<span class="kn">from</span> <span class="nn">sklearn.metrics</span> <span class="kn">import</span> <span class="n">accuracy_score</span>

<span class="k">def</span> <span class="nf">get_filters_accuracy</span><span class="p">(</span><span class="n">filters</span><span class="p">,</span> <span class="n">x_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">x_test</span><span class="p">,</span> <span class="n">y_test</span><span class="p">):</span>
    <span class="n">x_train_lda</span> <span class="o">=</span> <span class="n">x_train</span> <span class="o">@</span> <span class="n">filters</span><span class="p">.</span><span class="n">T</span>
    <span class="n">x_test_lda</span> <span class="o">=</span> <span class="n">x_test</span> <span class="o">@</span> <span class="n">filters</span><span class="p">.</span><span class="n">T</span>
    <span class="n">lda_classifier</span> <span class="o">=</span> <span class="n">LinearDiscriminantAnalysis</span><span class="p">()</span>
    <span class="n">lda_classifier</span><span class="p">.</span><span class="n">fit</span><span class="p">(</span><span class="n">x_train_lda</span><span class="p">,</span> <span class="n">y_train</span><span class="p">)</span>
    <span class="n">acc</span> <span class="o">=</span> <span class="n">lda_classifier</span><span class="p">.</span><span class="n">score</span><span class="p">(</span><span class="n">x_test_lda</span><span class="p">,</span> <span class="n">y_test</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">acc</span>

<span class="c1"># Compute accuracy of LDA filters
</span><span class="n">sample_acc</span> <span class="o">=</span> <span class="n">get_filters_accuracy</span><span class="p">(</span><span class="n">lda_filters_sample</span><span class="p">,</span> <span class="n">x_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">x_test</span><span class="p">,</span> <span class="n">y_test</span><span class="p">)</span>
<span class="n">oas_acc</span> <span class="o">=</span> <span class="n">get_filters_accuracy</span><span class="p">(</span><span class="n">lda_filters_oas</span><span class="p">,</span> <span class="n">x_train</span><span class="p">,</span> <span class="n">y_train</span><span class="p">,</span> <span class="n">x_test</span><span class="p">,</span> <span class="n">y_test</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Sample LDA accuracy: </span><span class="si">{</span><span class="n">sample_acc</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"OAS LDA accuracy: </span><span class="si">{</span><span class="n">oas_acc</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sample LDA accuracy: 0.79
OAS LDA accuracy: 0.83
</code></pre></div></div>

<p>We see that the OAS estimator of \(S_W\) provides a better classification accuracy
than the sample covariance estimator. This is a common result in
practice, as the OAS estimator provides a more stable estimate of the
covariance matrix. In fact, there is
<a href="https://scikit-learn.org/dev/auto_examples/classification/plot_lda.html">an sklearn tutorial</a> comparing
the performance of LDA on simulated data with different shrinkage
estimators. This problem has also been studied in the literature, for
example in the influential paper
<a href="https://www.tandfonline.com/doi/abs/10.1080/01621459.1989.10478752">Regularized Discriminant Analysis</a>.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this post, we learned that although the sample covariance matrix is a simple
and unbiased estimator of the true covariance matrix, it may not be the best
estimator for some applications. In particular, when the number of observations
is small compared to the number of dimensions, shrinkage estimators can provide
more stable and accurate estimates of the covariance matrix. We introduced a
type of linear shrinkage estimator, the Oracle Approximating Shrinkage (OAS),
which aims to minimize the MSE between the estimated and true covariance matrix.
Other shrinkage estimators exist, both of the non-linear type, and also aiming
to minimize other criteria, such as in the spectral domain.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1" role="doc-endnote">
      <p>The covariance matrix is symmetric, so the number of unique entries is given by those at and below the diagonal. The first row has \(1\) element at/below the diagonal, the second row has \(2\), and so on up to the \(n\)th row, which has \(p\) elements. So the number of unique elements equals the sum \(1 + 2 + \ldots + p\). Note that adding the first and last element equals \(p+1\), adding the second and second-to-last element also equals \(p+1\), and so on. So, we have \(p/2\) pairs of elements that sum to \(p+1\), resulting in the known formula \(1 + 2 + \ldots + p = \frac{(p+1)p}{2}\) <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>The Frobenius norm of a matrix \(A\) is defined as \(\| A \|_F = \sqrt{\sum_{i,j} A_{ij}^2}\), which also equal to \(\text{tr}(A^T A)\), where \(\text{tr}\) is the trace operator. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Statistics" /><category term="Estimation" /><category term="Python" /><summary type="html"><![CDATA[Covariance matrices are one of the most important objects in statistics and machine learning, essential to many algorithms. But estimating covariance matrices can be difficult, especially in high-dimensional settings. In this post we introduce covariance shrinkage, a technique to improve covariance matrix estimation. We also provide a PyTorch implementation of a popular shrinkage technique, the Oracle Approximating Shrinkage (OAS) estimator.]]></summary></entry><entry><title type="html">GitHub releases to keep your old projects from breaking</title><link href="https://dherrera1911.github.io/posts/2024/09/github-releases-to-keep-old-projects-from-breaking/" rel="alternate" type="text/html" title="GitHub releases to keep your old projects from breaking" /><published>2024-09-25T00:00:00-07:00</published><updated>2024-09-25T00:00:00-07:00</updated><id>https://dherrera1911.github.io/posts/2024/09/github-releases</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2024/09/github-releases-to-keep-old-projects-from-breaking/"><![CDATA[<p>If you are a scientist programming for your research, you probably
experienced the following. You wrote some code,
in a directory <code class="language-plaintext highlighter-rouge">my_code/</code>, or GitHub repository
<code class="language-plaintext highlighter-rouge">https://github.com/my_user/my_code</code>. You use this code in a
project, and analyses are working. Then, you
modify your software to add a new feature, required by a new
project or analysis. However, the new modified code
might (and probably will) break the original analysis.</p>

<p>Of course, you don’t want your old project to now become
unreplicable because of these changes. But you also
don’t want to make the old project work with the new code,
which can involve a lot of work.
One way to avoid this situation is to
use <a href="https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases">GitHub releases</a>.</p>

<h2 id="what-are-github-releases">What are GitHub releases?</h2>

<p>Here we’ll assume that you are familiar with
the very basics of GitHub, and that you know
how to create a repository, commit and push code.
If not, consider reading this
<a href="https://journals.plos.org/ploscompbiol/article?id=10.1371/journal.pcbi.1004668">guide for scientists in PLoS Comp Bio</a>
to start incorporating this amazing tool into your workflow.</p>

<p>The basic GitHub workflow of committing and pushing
does not protect you from the situation described
above, however. If you push changes to the repository,
the old project will use the new code and might break.</p>

<p>GitHub releases basically allow you to “freeze” a version
of your code that you want to be able to access
later (e.g. the version that works with the old project),
while still letting you work on the code and make
changes. You have probably seen this in many
software projects, where you can download a
specific version of the software, like
<code class="language-plaintext highlighter-rouge">useful_package_v1.0.0</code>, <code class="language-plaintext highlighter-rouge">useful_package_v1.0.1</code>, etc.
GitHub releases allow you to do the same thing
with your code, so that for different projects you
can specify which version of the code you and your
users should use.</p>

<h2 id="how-to-use-github-releases">How to use GitHub releases</h2>

<p>Using GitHub releases is quite simple, and the
<a href="https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository">GitHub documentation</a>
is clear and helpful.</p>

<p>Once you have the desired working version of your code
in your repository, all you need to do is go
to your repository, click on the “Releases” tab
on the right. Then a page to create the new release
will open up. You can give the release a tag (e.g. <code class="language-plaintext highlighter-rouge">v1.0.0</code>),
a title (e.g. <code class="language-plaintext highlighter-rouge">First release</code>), and a description (e.g.
<code class="language-plaintext highlighter-rouge">Project X works with this release</code>).</p>

<p>After you create the release, you can go back to the
“Releases” tab and see all the releases you have created.
You can download the code for a specific release
using the command line</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/my_user/my_packave --branch v1.0.0
</code></pre></div></div>
<p>In the repository of your older project, you can now
direct users to download the code from the release
that works with the project by using this command.
If you already have a <code class="language-plaintext highlighter-rouge">environment.yml</code> file that
you use to set up the environment for the project,
you can specify the version of the code that should be
used by adding the version tag to the URL, like this:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- git+https://github.com/my_user/my_package.git@v1.0.0
</code></pre></div></div>

<p>You can also just download the code by clicking on the release
and then on the “Source code” link.</p>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="GitHub" /><category term="Programming practices" /><category term="Project management" /><summary type="html"><![CDATA[If you are a scientist programming for your research, you probably experienced the following. You wrote some code, in a directory my_code/, or GitHub repository https://github.com/my_user/my_code. You use this code in a project, and analyses are working. Then, you modify your software to add a new feature, required by a new project or analysis. However, the new modified code might (and probably will) break the original analysis.]]></summary></entry><entry><title type="html">Easy constrained optimization in Pytorch with Parametrizations</title><link href="https://dherrera1911.github.io/posts/2024/08/constrained-learning-with-pytorch-parametrizations/" rel="alternate" type="text/html" title="Easy constrained optimization in Pytorch with Parametrizations" /><published>2024-08-21T00:00:00-07:00</published><updated>2024-08-21T00:00:00-07:00</updated><id>https://dherrera1911.github.io/posts/2024/08/constrained-learning-with-pytorch-parametrizations</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2024/08/constrained-learning-with-pytorch-parametrizations/"><![CDATA[<p>Often times, we want to optimize some model parameter while
keeping it constrained. For example, we might want a
parameter vector to have unit norm, a set of vectors to
be orthogonal with respect to each other, or a matrix
to be symmetric positive definite (SPD). For the specific
cases where the constraint is for the parameter to be on
a manifold, a common approach is to use Riemannian
optimization. However, there is a simpler and often
more efficient way to do constrained optimization: we can
use a technique called <strong>parametrization</strong>.</p>

<p>Parametrizations are a tool to turn a constrained optimization
problem into a simpler unconstrained optimization problem.
In this post we introduce parametrizations
and show how to implement them in Pytorch.
We will study two examples with synthetic data:
constraining a vector to have unit norm, and
constraining a matrix to be SPD.</p>

<p>We assume familiarity with PyTorch and basic optimization.
More in depth information on Pytorch parametrizations can be found in the
<a href="https://pytorch.org/tutorials/intermediate/parametrizations.html">Parametrizations tutorial</a>
(aimed at more advanced users).</p>

<h2 id="constrained-optimization">Constrained optimization</h2>

<p>When doing optimization in Pytorch, we usually have a parameter
\(\theta \in \mathbb{R}^n\) and a loss function \(L(\theta)\)
that we minimize with gradient descent.
For this, at each iteration we update \(\theta\) in the direction
of the negative gradient of \(L(\theta)\):</p>

\[\theta \leftarrow \theta - \alpha \nabla_{\theta} L(\theta)\]

<p>\(\nabla_{\theta} L(\theta)\)
is the gradient of the loss function with respect to \(\theta\)
and \(\alpha\) is the learning rate.</p>

<p>In constrained optimization, we also want to constrain
\(\theta\) to fulfill some condition, or equivalently,
to be in a certain subset \(C \subseteq \mathbb{R}^n\).
One simple example is constraining a vector \(\theta\)
such that \(\|\theta\| = 1\), or such that \(\theta \in C\),
where \(C\) is the unit sphere \(C = \{x: \|x \| = 1\}\).</p>

<p>When we introduce a constraint, we can no longer just
updating the parameter \(\theta\) in the direction of the
negative gradient. Doing so is likely to break the
constraint, e.g. it will drive \(\theta\) outside of the
unit sphere \(C\). So, how can we update the
parameter in such a way that it remains on \(C\)?</p>

<p>For the example of the sphere, 
an intuitive alternative is to project \(\theta\) back
onto the unit circle by \(\theta \leftarrow \theta / \| \theta \|\)
after each update. However, doing this naively
can introduce problems. Also, while projecting onto the circle
is straightforward, projecting onto other constraint sets (e.g.
orthonormal vectors) might be more difficult.</p>

<p>There are different approaches to constrained optimization.
Among these, parametrizations are a simple and efficient 
method that is easy to implement and that is popular in
machine learning.</p>

<h2 id="formalism-of-parametrizations">Formalism of parametrizations</h2>

<p>Parametrizations involve projecting onto the set
\(C\), but in a more principled way than the example
above. The idea is to introduce a new unconstrained
parameter \(\eta \in \mathbb{R}^m\), and a differentiable and surjective
function \(f(\eta) = \theta\) that maps
from values in \(\mathbb{R}^m\) to \(C\), \(f: \mathbb{R}^m \rightarrow C\).
Then, we do optimization on \(\eta\) instead
of \(\theta\), as follows:</p>

\[\eta \leftarrow \eta - \alpha \nabla_{\eta} L\left(f(\eta)\right)\]

<p>We use the same loss function as before, but
now composed with the function \(f\) so it is a
function of \(\eta\). And we now take the gradient with
respect to \(\eta\). Because \(\eta\) is unconstrained, we can
update it with gradient descent, taking advantage of the
highly efficient routines implemented for unconstrained
optimization in Pytorch. The parameter
of interest \(\theta\) is given by \(f(\eta)\) and it will
always satisfy the constraint.</p>

<p>For our example of constraining \(\theta\)
to be on the unit circle, we can parametrize \(\theta\)
with the function \(f(\eta) = \eta / \| \eta \|\)<sup id="fnref:4" role="doc-noteref"><a href="#fn:4" class="footnote" rel="footnote">1</a></sup>.</p>

<p>Lets see how we can implement this idea in Pytorch.</p>

<h2 id="example-average-on-a-circle-unconstrained">Example: Average on a circle, unconstrained</h2>

<p>First, introduce a simple problem to illustrate constrained
optimization: Finding the average of a set of points on the unit circle.</p>

<p>For this, we will have some data vectors \(x_i\) distributed in the
unit circle, and we want to find the vector \(\theta\) that
minimizes the squared distance to the data
\(L(\theta) = \sum_i \| \theta - x_i \|^2\). We also want \(\theta\)
to be on the circle.</p>

<p>Before showing how to solve the
constrained optimization problem, lets implement the
unconstrained optimization problem to use as a reference.</p>

<p>First we generate the data on the circle by
sampling points from the distribution
\(\mathcal{N}(\mu, \mathbb{I}\sigma^2)\) in \(\mathbb{R}^2\),
and dividing these points by their norm <sup id="fnref:1" role="doc-noteref"><a href="#fn:1" class="footnote" rel="footnote">2</a></sup>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### GENERATE THE DATA ON THE CIRCLE
</span><span class="kn">import</span> <span class="nn">torch</span>

<span class="c1"># Simulation parameters
</span><span class="n">n_dimensions</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
<span class="n">mu</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">ones</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span> <span class="o">/</span> <span class="n">torch</span><span class="p">.</span><span class="n">sqrt</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>
<span class="n">sigma</span> <span class="o">=</span> <span class="mi">1</span>
<span class="n">n_points</span> <span class="o">=</span> <span class="mi">200</span>

<span class="c1"># Generate Gaussian data
</span><span class="n">data_gauss</span> <span class="o">=</span> <span class="n">mu</span> <span class="o">+</span> <span class="n">torch</span><span class="p">.</span><span class="n">randn</span><span class="p">(</span><span class="n">n_points</span><span class="p">,</span> <span class="n">n_dimensions</span><span class="p">)</span> <span class="o">*</span> <span class="n">sigma</span>
<span class="c1"># Project to the unit sphere
</span><span class="n">data</span> <span class="o">=</span> <span class="n">data_gauss</span> <span class="o">/</span> <span class="n">data_gauss</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">keepdim</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div></div>

<p>We visualize the data</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### PLOT THE DATA
</span><span class="kn">import</span> <span class="nn">matplotlib.pyplot</span> <span class="k">as</span> <span class="n">plt</span>

<span class="k">def</span> <span class="nf">plot_circle_data</span><span class="p">(</span><span class="n">ax</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">title</span><span class="p">):</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">scatter</span><span class="p">(</span><span class="n">data</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">],</span> <span class="n">data</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">],</span> <span class="n">alpha</span><span class="o">=</span><span class="mf">0.4</span><span class="p">,</span> <span class="n">s</span><span class="o">=</span><span class="mi">12</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_xlim</span><span class="p">(</span><span class="o">-</span><span class="mf">1.1</span><span class="p">,</span> <span class="mf">1.1</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_ylim</span><span class="p">(</span><span class="o">-</span><span class="mf">1.1</span><span class="p">,</span> <span class="mf">1.1</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_xlabel</span><span class="p">(</span><span class="s">"x1"</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_ylabel</span><span class="p">(</span><span class="s">"x2"</span><span class="p">)</span>
    <span class="n">ax</span><span class="p">.</span><span class="n">set_title</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>

<span class="n">data_jitter</span> <span class="o">=</span> <span class="n">data</span> <span class="o">+</span> <span class="mf">0.01</span> <span class="o">*</span> <span class="n">torch</span><span class="p">.</span><span class="n">randn</span><span class="p">(</span><span class="n">n_points</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>

<span class="n">fig</span><span class="p">,</span> <span class="n">ax</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mf">3.8</span><span class="p">))</span>
<span class="n">plot_circle_data</span><span class="p">(</span><span class="n">ax</span><span class="p">,</span> <span class="n">data_jitter</span><span class="p">,</span> <span class="s">"Data on unit circle"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">tight_layout</span><span class="p">()</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/parametrizations/data.png" alt="" /></p>

<p>Next, we generate the Python class to perform the optimization.
Our class <code class="language-plaintext highlighter-rouge">AverageUnconstrained</code> has parameter vector <code class="language-plaintext highlighter-rouge">theta</code>,
and a function <code class="language-plaintext highlighter-rouge">forward</code> that computes the squared distance
of each point to <code class="language-plaintext highlighter-rouge">theta</code><sup id="fnref:2" role="doc-noteref"><a href="#fn:2" class="footnote" rel="footnote">3</a></sup>. We use the Pytorch <code class="language-plaintext highlighter-rouge">nn.Module</code> class to
define the model <sup id="fnref:3" role="doc-noteref"><a href="#fn:3" class="footnote" rel="footnote">4</a></sup>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### DEFINE CLASS FOR UNCONSTRAINED OPTIMIZATION
</span><span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span>

<span class="k">class</span> <span class="nc">AverageUnconstrained</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">dim</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="c1"># Initialize theta randomly
</span>        <span class="n">theta</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">randn</span><span class="p">(</span><span class="n">dim</span><span class="p">)</span>
        <span class="n">theta</span> <span class="o">=</span> <span class="n">theta</span> <span class="o">/</span> <span class="n">torch</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">theta</span><span class="p">)</span>
        <span class="c1"># Make theta a parameter so it is optimized by Pytorch
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">theta</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">theta</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">):</span>
        <span class="c1"># Compute the distance of each point to theta
</span>        <span class="n">difference</span> <span class="o">=</span> <span class="n">x</span> <span class="o">-</span> <span class="bp">self</span><span class="p">.</span><span class="n">theta</span>
        <span class="n">distance_squared</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">sum</span><span class="p">(</span><span class="n">difference</span><span class="o">**</span><span class="mi">2</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">distance_squared</span>

</code></pre></div></div>

<p>Next, we define a function <code class="language-plaintext highlighter-rouge">loss_function</code> that computes the
loss by taking the average of the squared distances.
We also define the function <code class="language-plaintext highlighter-rouge">train_model</code> that
performs gradient descent on the model parameters:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### DEFINE LOSS FUNCTION AND OPTIMIZATION FUNCTION
# Loss function
</span><span class="k">def</span> <span class="nf">loss_function</span><span class="p">(</span><span class="n">loss_vector</span><span class="p">):</span>
    <span class="n">loss</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">loss_vector</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">loss</span>

<span class="c1"># Optimization function
</span><span class="k">def</span> <span class="nf">train_model</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">n_iterations</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.1</span><span class="p">):</span>
    <span class="c1"># We initialize an optimizer for the model parameters
</span>    <span class="n">optimizer</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">optim</span><span class="p">.</span><span class="n">Adam</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">parameters</span><span class="p">(),</span> <span class="n">lr</span><span class="o">=</span><span class="n">lr</span><span class="p">)</span>

    <span class="c1"># We take n_iterations steps of gradient descent
</span>    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">n_iterations</span><span class="p">):</span>
        <span class="n">optimizer</span><span class="p">.</span><span class="n">zero_grad</span><span class="p">()</span>
        <span class="n">loss_vector</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>
        <span class="n">loss</span> <span class="o">=</span> <span class="n">loss_function</span><span class="p">(</span><span class="n">loss_vector</span><span class="p">)</span>
        <span class="n">loss</span><span class="p">.</span><span class="n">backward</span><span class="p">()</span>
        <span class="n">optimizer</span><span class="p">.</span><span class="n">step</span><span class="p">()</span>
</code></pre></div></div>

<p>Lets use these functions to optimize the model and
visualize the result.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### OPTIMIZE THE THETA AND VISUALIZE
# Initialize the model
</span><span class="n">model_unconstrained</span> <span class="o">=</span> <span class="n">AverageUnconstrained</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>

<span class="c1"># Fit the model
</span><span class="n">train_model</span><span class="p">(</span><span class="n">model_unconstrained</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">n_iterations</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.1</span><span class="p">)</span>

<span class="c1"># Visualize the theta learned by the model
</span><span class="n">theta</span> <span class="o">=</span> <span class="n">model_unconstrained</span><span class="p">.</span><span class="n">theta</span><span class="p">.</span><span class="n">detach</span><span class="p">().</span><span class="n">numpy</span><span class="p">()</span>

<span class="n">fig</span><span class="p">,</span> <span class="n">ax</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mf">3.8</span><span class="p">))</span>
<span class="n">plot_circle_data</span><span class="p">(</span><span class="n">ax</span><span class="p">,</span> <span class="n">data_jitter</span><span class="p">,</span> <span class="s">"Unconstrained theta"</span><span class="p">)</span>
<span class="n">ax</span><span class="p">.</span><span class="n">scatter</span><span class="p">(</span><span class="n">theta</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">theta</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">color</span><span class="o">=</span><span class="s">"red"</span><span class="p">,</span> <span class="n">s</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="s">"theta"</span><span class="p">)</span>
<span class="n">ax</span><span class="p">.</span><span class="n">legend</span><span class="p">(</span><span class="n">loc</span><span class="o">=</span><span class="s">"upper left"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/parametrizations/unconstrained.png" alt="" /></p>

<p>We see that the learned point is not on the circle, as we would
expect since we did not add any constraint.</p>

<h2 id="implementation-of-unit-circle-constrain-in-pytorch">Implementation of unit circle constrain in Pytorch</h2>

<p>We now implement a model that does constrained optimization
using parametrizations. For this, we will use the Pytorch
tool <code class="language-plaintext highlighter-rouge">torch.nn.utils.parametrize</code>, which takes care
of a lot of the software bookkeeping for us, and can be
implemented with minimal changes to our original code.</p>

<p><strong>How to implement \(f\) for Pytorch parametrizations</strong></p>

<p>To use <code class="language-plaintext highlighter-rouge">parametrize</code>, we need to define the function \(f\)
inside an <code class="language-plaintext highlighter-rouge">nn.Module</code> class, implemented in the method 
<code class="language-plaintext highlighter-rouge">forward</code> inside this class. Let’s
see how this looks like in our example:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Define the parametrization function f
</span><span class="k">class</span> <span class="nc">NormalizeVector</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">eta</span><span class="p">):</span>
        <span class="n">theta</span> <span class="o">=</span> <span class="n">eta</span> <span class="o">/</span> <span class="n">eta</span><span class="p">.</span><span class="n">norm</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">theta</span>
</code></pre></div></div>

<p>The method <code class="language-plaintext highlighter-rouge">forward</code> implements \(f\)
by taking vector <code class="language-plaintext highlighter-rouge">eta</code> and returning the normalized
vector <code class="language-plaintext highlighter-rouge">theta</code> with a length of 1 (the names of the variables
don’t have to be <code class="language-plaintext highlighter-rouge">eta</code> and <code class="language-plaintext highlighter-rouge">theta</code>).</p>

<p>Next, let’s use <code class="language-plaintext highlighter-rouge">parametrize</code> to create a new class
where <code class="language-plaintext highlighter-rouge">theta</code> is constrained to be on the unit circle.
This is done by adding only one line to our original
unconstrained class:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### DEFINE CLASS FOR CONSTRAINED OPTIMIZATION
</span><span class="kn">from</span> <span class="nn">torch.nn.utils</span> <span class="kn">import</span> <span class="n">parametrize</span>

<span class="k">class</span> <span class="nc">AverageInCircle</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">dim</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="c1"># Initialize theta randomly
</span>        <span class="n">theta</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">randn</span><span class="p">(</span><span class="n">dim</span><span class="p">)</span>
        <span class="n">theta</span> <span class="o">=</span> <span class="n">theta</span> <span class="o">/</span> <span class="n">torch</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">theta</span><span class="p">)</span>
        <span class="c1"># Make theta a parameter so it is optimized by Pytorch
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">theta</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">theta</span><span class="p">)</span>

        <span class="c1">### ONLY CHANGE: Add the parametrization in terms of f to theta
</span>        <span class="n">parametrize</span><span class="p">.</span><span class="n">register_parametrization</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="s">"theta"</span><span class="p">,</span> <span class="n">NormalizeVector</span><span class="p">())</span>


    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">):</span>
        <span class="c1"># Compute the distance of each point to theta
</span>        <span class="n">difference</span> <span class="o">=</span> <span class="n">x</span> <span class="o">-</span> <span class="bp">self</span><span class="p">.</span><span class="n">theta</span>
        <span class="n">distance_squared</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">sum</span><span class="p">(</span><span class="n">difference</span><span class="o">**</span><span class="mi">2</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">distance_squared</span>
</code></pre></div></div>

<p>Now, the unconstrained <code class="language-plaintext highlighter-rouge">eta</code> parameter that is actually
being updated by gradient descent is taken care of in the background.
Importantly, the code for optimizing the constrained model doesn’t
change. Let’s optimize the model and see the result.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### OPTIMIZE THE CONSTRAINED THETA AND VISUALIZE
# Initialize the model
</span><span class="n">model_circle</span> <span class="o">=</span> <span class="n">AverageInCircle</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>

<span class="c1"># Fit the model
</span><span class="n">train_model</span><span class="p">(</span><span class="n">model_circle</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">n_iterations</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.1</span><span class="p">)</span>

<span class="c1"># Visualize the theta learned by the model
</span><span class="n">theta_circle</span> <span class="o">=</span> <span class="n">model_circle</span><span class="p">.</span><span class="n">theta</span><span class="p">.</span><span class="n">detach</span><span class="p">()</span>

<span class="n">fig</span><span class="p">,</span> <span class="n">ax</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">4</span><span class="p">,</span> <span class="mf">3.8</span><span class="p">))</span>
<span class="n">plot_circle_data</span><span class="p">(</span><span class="n">ax</span><span class="p">,</span> <span class="n">data_jitter</span><span class="p">,</span> <span class="s">"Parametrized theta"</span><span class="p">)</span>
<span class="n">ax</span><span class="p">.</span><span class="n">scatter</span><span class="p">(</span><span class="n">theta_circle</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">theta_circle</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">color</span><span class="o">=</span><span class="s">"red"</span><span class="p">,</span> <span class="n">s</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="s">"theta"</span><span class="p">)</span>
<span class="n">ax</span><span class="p">.</span><span class="n">legend</span><span class="p">(</span><span class="n">loc</span><span class="o">=</span><span class="s">"upper left"</span><span class="p">)</span>
<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>

<p><img src="/files/blog/parametrizations/constrained.png" alt="" /></p>

<p>Now in this parametrized model the optimized <code class="language-plaintext highlighter-rouge">theta</code> is on the unit
circle.</p>

<h2 id="implementation-of-symmetric-positive-definite-parametrization-in-pytorch">Implementation of symmetric positive definite parametrization in Pytorch</h2>

<p>Let’s look at a more complicated example that appears often
in statistics and machine learning: optimization of a matrix
constrained to be SPD<sup id="fnref:5" role="doc-noteref"><a href="#fn:5" class="footnote" rel="footnote">5</a></sup>. This is a common problem because
covariance matrices and many other
important mathematical objects are SPD matrices.</p>

<p>There are some well-known parametrizations for SPD matrices,
and we will show how to implement two of them in Pytorch:
the Log-Cholesky parametrization, and the matrix logarithm
parametrization (see the article
<a href="https://link.springer.com/article/10.1007/BF00140873">Unconstrained parametrizations for variance-covariance matrices</a> for an overview).</p>

<p>Remember, to define a parametrization we need to define
a function \(f\) that maps from the unconstrained parameter
space to the space of SPD matrices. So, what we will do below
is describe functions \(f\) that map from the unconstrained
space of lower-triangular matrices (for the Log-Cholesky
parametrization) and symmetric matrices (for the matrix
logarithm parametrization) to the space of SPD matrices.</p>

<p><strong>Log-Cholesky parametrization</strong></p>

<p>The Log-Cholesky parametrization uses the property of
SPD matrices that they can be decomposed as \(\Sigma = LL^T\),
where \(L\) is a lower triangular matrix with
positive diagonal elements (in this section we refer
to our model parameter as \(\Sigma\) instead of \(\theta\)).
This is called the Cholesky decomposition of \(\Sigma\),
and it is unique.</p>

<p>A parametrization in terms of \(L\) though would not
work, because \(L\) is constrained to have positive
diagonal elements. The Log-Cholesky parametrization
gets rid of this constraint by taking the logarithm
of the diagonal elements of \(L\), resulting in an unconstrained
lower-triangular matrix \(M\). Let’s see how we can
define an \(f(M)=\Sigma\) according to this reasoning.</p>

<p>Let \(\lfloor M \rfloor\) denote the matrix with only
the strictly lower-triangular part of lower-triangular
matrix \(M\), and \(\mathbb{D}(M)\) the diagonal matrix
with the diagonal elements of \(M\). Then,</p>

\[M = \mathbb{D}(M) + \lfloor M \rfloor\]

\[L = e^{\mathbb{D}(M)} + \lfloor M \rfloor\]

<p>(note that to take the exponential of a diagonal matrix
we just take the exponential of the diagonal elements).
Then, the function \(f\) that maps from the unconstrained
space of lower triangular matrices
\(\mathbb{R}^{\frac{n(n+1)}{2}}\)<sup id="fnref:6" role="doc-noteref"><a href="#fn:6" class="footnote" rel="footnote">6</a></sup> to the
SPD matrices is defined as follows:</p>

\[f(M) = \left[ e^{\mathbb{D}(M)} + \lfloor M \rfloor \right] \left[ e^{\mathbb{D}(M)} + \lfloor M \rfloor \right]^T = L L^T = \Sigma\]

<p>Thus, we have a function that we can use to define our
parametrization in terms of unconstrained parameter \(M\).</p>

<p>Before we implement this in Pytorch,
we should note that there is one thing that
<code class="language-plaintext highlighter-rouge">parametrize</code> needs some more help with: assigning specific
values to the parameter \(\Sigma\).</p>

<p><strong>Right-inverse function for assigning the constrained parameter</strong></p>

<p>Suppose that we want to assign a specific value \(\Sigma'\)
to the parameter \(\Sigma\), in our parametrized Pytorch model (e.g. 
we want to initialize it in a certain way). But in the
parametrized model, there is a parameter \(M\)
in the background that gives us \(\Sigma\) by
\(f(M) = \Sigma\). Thus, we can’t just assign a value
\(\Sigma'\) to \(\Sigma\), we need to assign a value \(M'\) to
\(M\) such that \(f(M') = \Sigma'\). The <code class="language-plaintext highlighter-rouge">parametrize</code> tool
takes care of the details of this, but it needs to be
given a function that maps from \(\Sigma\) to \(M\), which
is called <code class="language-plaintext highlighter-rouge">right-inverse</code> in the class with function \(f\).</p>

<p>We already described the right-inverse function for our
Log-Cholesky parametrization when we
explained how to get \(M\) from \(\Sigma\):
1) Take the Cholesky decomposition of \(\Sigma\) to get \(L\)
2) Take the logarithm of the diagonal elements of \(L\) to get \(M\)</p>

<p>Let’s now implement both the parametrization function \(f\)
and the <code class="language-plaintext highlighter-rouge">right-inverse</code> function for the Log-Cholesky
parametrization in Pytorch:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### IMPLEMENTATION OF LOG-CHOLESKY PARAMETRIZATION
</span>
<span class="c1"># Log-Cholesky parametrization
</span><span class="k">class</span> <span class="nc">SPDLogCholesky</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">M</span><span class="p">):</span>
        <span class="c1"># Take strictly lower triangular matrix
</span>        <span class="n">M_strict</span> <span class="o">=</span> <span class="n">M</span><span class="p">.</span><span class="n">tril</span><span class="p">(</span><span class="n">diagonal</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
        <span class="c1"># Make matrix with exponentiated diagonal
</span>        <span class="n">D</span> <span class="o">=</span> <span class="n">M</span><span class="p">.</span><span class="n">diag</span><span class="p">()</span>
        <span class="c1"># Make the Cholesky decomposition matrix
</span>        <span class="n">L</span> <span class="o">=</span> <span class="n">M_strict</span> <span class="o">+</span> <span class="n">torch</span><span class="p">.</span><span class="n">diag</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">exp</span><span class="p">(</span><span class="n">D</span><span class="p">))</span>
        <span class="c1"># Invert the Cholesky decomposition
</span>        <span class="n">Sigma</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">matmul</span><span class="p">(</span><span class="n">L</span><span class="p">,</span> <span class="n">L</span><span class="p">.</span><span class="n">t</span><span class="p">())</span>
        <span class="k">return</span> <span class="n">Sigma</span>

    <span class="k">def</span> <span class="nf">right_inverse</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">):</span>
        <span class="c1"># Compute the Cholesky decomposition
</span>        <span class="n">L</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">cholesky</span><span class="p">(</span><span class="n">Sigma</span><span class="p">)</span>
        <span class="c1"># Take strictly lower triangular matrix
</span>        <span class="n">M_strict</span> <span class="o">=</span> <span class="n">L</span><span class="p">.</span><span class="n">tril</span><span class="p">(</span><span class="n">diagonal</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
        <span class="c1"># Take the logarithm of the diagonal
</span>        <span class="n">D</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">diag</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="n">L</span><span class="p">.</span><span class="n">diag</span><span class="p">()))</span>
        <span class="c1"># Return the log-Cholesky parametrization
</span>        <span class="n">M</span> <span class="o">=</span> <span class="n">M_strict</span> <span class="o">+</span> <span class="n">D</span>
        <span class="k">return</span> <span class="n">M</span>
</code></pre></div></div>

<p>We are now ready to implement a model that optimizes a matrix
while constraining it to be SPD, using the Log-Cholesky parametrization.
Let’s set up a problem to test this optimization.</p>

<p><strong>Estimating a covariance matrix with missing data</strong></p>

<p>We will use a problem suggested by a user at
<a href="https://stats.stackexchange.com/a/653094/134438">CrossValidated</a>.
The problem is estimating the covariance matrix of a dataset
where some observations are missing, which is a common problem
with real-world datasets.</p>

<p>We have a dataset \(X\) with \(n\) rows and \(p\) columns,
where each row is an observation and
each column is a variable. The dataset is missing
some entries \(X_{ij}\) completely at random. The
problem is that we want to estimate the covariance matrix
of \(X\), which we call \(\Sigma\).</p>

<p>To estimate any given element \(\Sigma_{kl}\), we could use only
the rows of \(X\) where both columns \(k\) and \(l\) are
observed. However, this procedure does not guarantee
that the resulting matrix is SPD. To solve this problem,
we will do maximum-likelihood estimation of the covariance,
ignoring the missing values. We will implement models to
optimize \(\Sigma\) both without constraint and
with the Log-Cholesky parametrization, and compare the results.</p>

<p>First, we generate a dataset for this problem. We start by
generating a mean <code class="language-plaintext highlighter-rouge">mu_true</code> and a covariance matrix
<code class="language-plaintext highlighter-rouge">Sigma_true</code> (we use our already defined \(f\) function
to generate a random <code class="language-plaintext highlighter-rouge">Sigma_true</code> from a random <code class="language-plaintext highlighter-rouge">M</code>)</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### GENERATE DATA PARAMETERS
</span>
<span class="c1"># Set random seed
</span><span class="n">torch</span><span class="p">.</span><span class="n">manual_seed</span><span class="p">(</span><span class="mi">1911</span><span class="p">)</span>

<span class="c1"># Generate the distribution parameters
</span><span class="n">n_dimensions</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
<span class="n">mu_true</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">ones</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>
<span class="n">M</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">randn</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">,</span> <span class="n">n_dimensions</span><span class="p">)</span> <span class="o">/</span> <span class="mi">10</span>
<span class="n">log_chol_par</span> <span class="o">=</span> <span class="n">SPDLogCholesky</span><span class="p">()</span>
<span class="n">Sigma_true</span> <span class="o">=</span> <span class="n">log_chol_par</span><span class="p">.</span><span class="n">forward</span><span class="p">(</span><span class="n">M</span><span class="p">)</span>
</code></pre></div></div>

<p>Next, we generate the data by sampling from
\(\mathcal{N}(\mu_{\text{true}}, \Sigma_{\text{true}})\),
and setting some entries to be missing at random.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### GENERATE GAUSSIAN DATA WITH MISSING VALUES
</span>
<span class="kn">from</span> <span class="nn">torch.distributions</span> <span class="kn">import</span> <span class="n">MultivariateNormal</span>

<span class="c1"># Generate data
</span><span class="n">n_points</span> <span class="o">=</span> <span class="mi">200</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">MultivariateNormal</span><span class="p">(</span><span class="n">mu_true</span><span class="p">,</span> <span class="n">Sigma_true</span><span class="p">).</span><span class="n">sample</span><span class="p">((</span><span class="n">n_points</span><span class="p">,))</span>

<span class="c1"># Remove random datapoints
</span><span class="n">mask</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">rand</span><span class="p">(</span><span class="n">n_points</span><span class="p">,</span> <span class="n">n_dimensions</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mf">0.2</span>
<span class="c1"># Make data where mask is False NaN
</span><span class="n">data</span><span class="p">[</span><span class="o">~</span><span class="n">mask</span><span class="p">]</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="s">"nan"</span><span class="p">)</span>

<span class="c1"># Print the first 8 rows of the data
</span><span class="k">print</span><span class="p">(</span><span class="n">data</span><span class="p">[:</span><span class="mi">8</span><span class="p">])</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tensor([[ 1.1146,  0.4793,  1.4138,  0.7827,     nan],
        [-0.9918,     nan,  0.4070,  1.7971,  1.4161],
        [ 1.3888,  1.8095,  0.9842,  0.4521, -1.9231],
        [ 0.9323, -0.0999,     nan,     nan, -0.9474],
        [ 2.3576,  1.7566,     nan,  0.3555,  0.7441],
        [ 1.8579,  1.3301,  1.1172, -0.0374,  2.5136],
        [ 1.0750,  1.2708,     nan, -0.4027,     nan],
        [ 0.7755,  2.7918,  1.0426,  1.2220,     nan]])
</code></pre></div></div>

<p>Next, we implement models that compute the negative log-likelihood
of the data under a Gaussian distribution with parameters
<code class="language-plaintext highlighter-rouge">mu</code> and <code class="language-plaintext highlighter-rouge">Sigma</code>, while ignoring missing values.</p>

<p>For a given data point \(x_i\) with missing values, we
compute the log-likelihood of the observed values
by using only the corresponding elements of <code class="language-plaintext highlighter-rouge">mu</code> and <code class="language-plaintext highlighter-rouge">Sigma</code>.
That is, if a row has missing values for the columns \(1\)
and \(3\), we will remove the elements \(1\) and \(3\) from the <code class="language-plaintext highlighter-rouge">mu</code>
and the rows and columns \(1\) and \(3\) from the <code class="language-plaintext highlighter-rouge">Sigma</code>,
and we will use the remaining elements to compute the
log-likelihood with a Gaussian distribution of lower dimension.</p>

<p>We first implement three useful functions:
<code class="language-plaintext highlighter-rouge">gaussian_log_likelihood</code> computes the
log-likelihood of a data point under a Gaussian distribution,
<code class="language-plaintext highlighter-rouge">remove_nan_statistics</code> takes as input the statistics <code class="language-plaintext highlighter-rouge">mu</code> and
<code class="language-plaintext highlighter-rouge">Sigma</code> and returns the statistics with only the elements corresponding
to the observed data, and <code class="language-plaintext highlighter-rouge">nll_observed_data</code> computes the negative
log-likelihood of the dataset with missing values as described in the
previous paragraph.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### IMPLEMENT FUNCTIONS TO COMPUTE MISSING DATA LOG-LIKELIHOOD
</span>
<span class="k">def</span> <span class="nf">gaussian_log_likelihood</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">):</span>
    <span class="c1"># Compute the log likelihood of a single
</span>    <span class="c1"># data point under a Gaussian distribution
</span>    <span class="n">n_dimensions</span> <span class="o">=</span> <span class="n">data</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="c1"># Subtract the mean from the data
</span>    <span class="n">diff</span> <span class="o">=</span> <span class="n">data</span> <span class="o">-</span> <span class="n">mu</span>
    <span class="c1"># Compute the quadratic term
</span>    <span class="n">quadratic</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">einsum</span><span class="p">(</span><span class="s">'i,ij,j-&gt;'</span><span class="p">,</span> <span class="n">diff</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">.</span><span class="n">inverse</span><span class="p">(),</span> <span class="n">diff</span><span class="p">)</span>
    <span class="c1"># Compute the gaussian log-likelihood
</span>    <span class="n">log_likelihood</span> <span class="o">=</span> <span class="o">-</span><span class="mf">0.5</span> <span class="o">*</span> <span class="p">(</span><span class="n">n_dimensions</span> <span class="o">*</span> <span class="n">torch</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="mf">2.0</span> <span class="o">*</span> <span class="n">torch</span><span class="p">.</span><span class="n">pi</span><span class="p">))</span> \
                             <span class="o">+</span> <span class="n">torch</span><span class="p">.</span><span class="n">slogdet</span><span class="p">(</span><span class="n">Sigma</span><span class="p">)[</span><span class="mi">1</span><span class="p">]</span> \
                             <span class="o">+</span> <span class="n">quadratic</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">log_likelihood</span>


<span class="k">def</span> <span class="nf">remove_nan_statistics</span><span class="p">(</span><span class="n">mu</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">,</span> <span class="n">nan_indices</span><span class="p">):</span>
    <span class="c1"># Remove the missing value elements from the mean and covariance
</span>    <span class="n">mu_no_nan</span> <span class="o">=</span> <span class="n">mu</span><span class="p">[</span><span class="o">~</span><span class="n">nan_indices</span><span class="p">]</span>
    <span class="n">Sigma_no_nan</span> <span class="o">=</span> <span class="n">Sigma</span><span class="p">[</span><span class="o">~</span><span class="n">nan_indices</span><span class="p">][:,</span> <span class="o">~</span><span class="n">nan_indices</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">mu_no_nan</span><span class="p">,</span> <span class="n">Sigma_no_nan</span>


<span class="k">def</span> <span class="nf">nll_observed_data</span><span class="p">(</span><span class="n">mu</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
    <span class="c1"># Compute the negative log-likelihood of the data under the
</span>    <span class="c1"># Gaussian distribution, ignoring NaN values
</span>    <span class="n">ll</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]):</span>
        <span class="c1"># Get NaN indices for this row
</span>        <span class="n">nan_indices</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">isnan</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>
        <span class="c1"># Remove the NaN columns from the statistics
</span>        <span class="n">mu_no_nan</span><span class="p">,</span> <span class="n">Sigma_no_nan</span> <span class="o">=</span> <span class="n">remove_nan_statistics</span><span class="p">(</span><span class="n">mu</span><span class="p">,</span>
                                                        <span class="n">Sigma</span><span class="p">,</span>
                                                        <span class="n">nan_indices</span><span class="p">)</span>
        <span class="c1"># Remove NaN columns from the data
</span>        <span class="n">data_no_nan</span> <span class="o">=</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">][</span><span class="o">~</span><span class="n">nan_indices</span><span class="p">]</span>
        <span class="c1"># Compute the likelihood of the observed data
</span>        <span class="n">ll</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">gaussian_log_likelihood</span><span class="p">(</span><span class="n">data_no_nan</span><span class="p">,</span>
                                        <span class="n">mu_no_nan</span><span class="p">,</span>
                                        <span class="n">Sigma_no_nan</span><span class="p">)</span>
    <span class="k">return</span> <span class="o">-</span><span class="n">ll</span>
</code></pre></div></div>

<p>Now, we implement the model that computes the negative log-likelihood
as described above, and we don’t constrain <code class="language-plaintext highlighter-rouge">Sigma</code> to be SPD:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### IMPLEMENT UNCONSTRAINED MODEL TO COMPUTE MISSING DATA NEGATIVE LL
</span>
<span class="k">class</span> <span class="nc">NLLObserved</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">mu</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">mu</span><span class="p">.</span><span class="n">clone</span><span class="p">())</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">Sigma</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">Sigma</span><span class="p">.</span><span class="n">clone</span><span class="p">())</span>

    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
        <span class="n">nll</span> <span class="o">=</span> <span class="n">nll_observed_data</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">mu</span><span class="p">,</span> <span class="bp">self</span><span class="p">.</span><span class="n">Sigma</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">nll</span>
</code></pre></div></div>

<p>We then implement the model that uses the Log-Cholesky parametrization,
which only requires adding one line to the previous model:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### IMPLEMENT LOG-CHOLESKY MODEL TO COMPUTE MISSING DATA NEGATIVE LL
</span>
<span class="k">class</span> <span class="nc">NLLObservedCholesky</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mu</span><span class="p">,</span> <span class="n">Sigma</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">mu</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">mu</span><span class="p">.</span><span class="n">clone</span><span class="p">())</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">Sigma</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">Parameter</span><span class="p">(</span><span class="n">Sigma</span><span class="p">.</span><span class="n">clone</span><span class="p">())</span>

        <span class="c1">### ONLY CHANGE: Add the parametrization in terms of f to Sigma
</span>        <span class="n">parametrize</span><span class="p">.</span><span class="n">register_parametrization</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="s">"Sigma"</span><span class="p">,</span> <span class="n">SPDLogCholesky</span><span class="p">())</span>

    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
        <span class="n">nll</span> <span class="o">=</span> <span class="n">nll_observed_data</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">mu</span><span class="p">,</span> <span class="bp">self</span><span class="p">.</span><span class="n">Sigma</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">nll</span>

</code></pre></div></div>

<p>We are now ready to optimize the models and compare the results.
Because of how we set up the functions, we can use the same
<code class="language-plaintext highlighter-rouge">train_model</code> function as in the previous example.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### OPTIMIZE THE MODELS
</span>
<span class="c1"># Generate initial parameters
</span><span class="n">mu_init</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>
<span class="n">Sigma_init</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">eye</span><span class="p">(</span><span class="n">n_dimensions</span><span class="p">)</span>

<span class="c1"># Initialize the models
</span><span class="n">model_unconstrained</span> <span class="o">=</span> <span class="n">NLLObserved</span><span class="p">(</span><span class="n">mu_init</span><span class="p">,</span> <span class="n">Sigma_init</span><span class="p">)</span>
<span class="n">model_cholesky</span> <span class="o">=</span> <span class="n">NLLObservedCholesky</span><span class="p">(</span><span class="n">mu_init</span><span class="p">,</span> <span class="n">Sigma_init</span><span class="p">)</span>

<span class="c1"># Fit the models
</span><span class="n">train_model</span><span class="p">(</span><span class="n">model_unconstrained</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">n_iterations</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.1</span><span class="p">)</span>
<span class="n">train_model</span><span class="p">(</span><span class="n">model_cholesky</span><span class="p">,</span> <span class="n">data</span><span class="p">,</span> <span class="n">n_iterations</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">lr</span><span class="o">=</span><span class="mf">0.1</span><span class="p">)</span>
</code></pre></div></div>

<p>Let’s first check whether the covariance matrices are
SPD for each model:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### CHECK IF COVARIANCE MATRICES ARE SPD
</span>
<span class="n">eigenvalues_unconstrained</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">eigh</span><span class="p">(</span><span class="n">model_unconstrained</span><span class="p">.</span><span class="n">Sigma</span><span class="p">.</span><span class="n">detach</span><span class="p">())</span>
<span class="n">eigenvalues_cholesky</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">eigh</span><span class="p">(</span><span class="n">model_cholesky</span><span class="p">.</span><span class="n">Sigma</span><span class="p">.</span><span class="n">detach</span><span class="p">())</span>

<span class="k">print</span><span class="p">(</span><span class="s">"Minimum eigenvalue unconstrained model:"</span><span class="p">,</span> <span class="n">eigenvalues_unconstrained</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nb">min</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="s">"Minimum eigenvalue Cholesky model:"</span><span class="p">,</span> <span class="n">eigenvalues_cholesky</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nb">min</span><span class="p">())</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Minimum eigenvalue unconstrained model: tensor(-3.0754)
Minimum eigenvalue Cholesky model: tensor(0.4976)
</code></pre></div></div>

<p>The covariance matrix of the unconstrained model is not
positive definite, while the covariance matrix of the
Log-Cholesky parametrized model is positive definite.
Let’s see how the estimated covariances compare to the true
covariance matrix:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### VISUALIZE THE LEARNED COVARIANCE MATRICES
</span>
<span class="c1"># Visualize the learned Sigmas
</span><span class="n">fig</span><span class="p">,</span> <span class="n">ax</span> <span class="o">=</span> <span class="n">plt</span><span class="p">.</span><span class="n">subplots</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="n">figsize</span><span class="o">=</span><span class="p">(</span><span class="mi">9</span><span class="p">,</span> <span class="mi">3</span><span class="p">))</span>

<span class="n">vmax</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">max</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="nb">abs</span><span class="p">(</span><span class="n">Sigma_true</span><span class="p">))</span>
<span class="n">vmin</span> <span class="o">=</span> <span class="o">-</span><span class="n">vmax</span>

<span class="c1"># True Sigma
</span><span class="n">im0</span><span class="o">=</span><span class="n">ax</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">Sigma_true</span><span class="p">,</span> <span class="n">cmap</span><span class="o">=</span><span class="s">"coolwarm"</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=</span><span class="n">vmin</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">vmax</span><span class="p">)</span>
<span class="n">ax</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="sa">r</span><span class="s">"True $\Sigma$"</span><span class="p">)</span>
<span class="c1"># Learned Sigma
</span><span class="n">ax</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">model_unconstrained</span><span class="p">.</span><span class="n">Sigma</span><span class="p">.</span><span class="n">detach</span><span class="p">(),</span> <span class="n">cmap</span><span class="o">=</span><span class="s">"coolwarm"</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=</span><span class="n">vmin</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">vmax</span><span class="p">)</span>
<span class="n">ax</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="sa">r</span><span class="s">"Unconstrained $\Sigma$"</span><span class="p">)</span>
<span class="c1"># Learned Sigma with Cholesky parametrization
</span><span class="n">ax</span><span class="p">[</span><span class="mi">2</span><span class="p">].</span><span class="n">imshow</span><span class="p">(</span><span class="n">model_cholesky</span><span class="p">.</span><span class="n">Sigma</span><span class="p">.</span><span class="n">detach</span><span class="p">(),</span> <span class="n">cmap</span><span class="o">=</span><span class="s">"coolwarm"</span><span class="p">,</span> <span class="n">vmin</span><span class="o">=</span><span class="n">vmin</span><span class="p">,</span> <span class="n">vmax</span><span class="o">=</span><span class="n">vmax</span><span class="p">)</span>
<span class="n">ax</span><span class="p">[</span><span class="mi">2</span><span class="p">].</span><span class="n">set_title</span><span class="p">(</span><span class="sa">r</span><span class="s">"Log-Cholesky parametrized $\Sigma$"</span><span class="p">)</span>
<span class="c1"># Add a colorbar to the right of the subplots
</span><span class="n">cbar</span> <span class="o">=</span> <span class="n">fig</span><span class="p">.</span><span class="n">colorbar</span><span class="p">(</span><span class="n">im0</span><span class="p">,</span> <span class="n">ax</span><span class="o">=</span><span class="n">ax</span><span class="p">.</span><span class="n">ravel</span><span class="p">().</span><span class="n">tolist</span><span class="p">(),</span> <span class="n">shrink</span><span class="o">=</span><span class="mf">0.95</span><span class="p">,</span>
                    <span class="n">cax</span><span class="o">=</span><span class="n">plt</span><span class="p">.</span><span class="n">axes</span><span class="p">([</span><span class="mf">0.93</span><span class="p">,</span> <span class="mf">0.15</span><span class="p">,</span> <span class="mf">0.02</span><span class="p">,</span> <span class="mf">0.7</span><span class="p">]))</span>

<span class="n">plt</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>
</code></pre></div></div>
<p><img src="/files/blog/parametrizations/covariances.png" alt="" /></p>

<p>We see that not only is the covariance matrix of the constrained
model positive definite, but it is also much closer to the
true covariance matrix than the unconstrained model!</p>

<p><strong>Matrix logarithm parametrization</strong></p>

<p>That’s all we are going to show about parametrizations,
but we want to show one more example of a parametrization,
since everyone loves parametrizations of SPD matrices.
We will not go into detail on
this parametrization, or use it to solve our problem, but
just show how to implement it in Pytorch.</p>

<p>The logarithm and exponential of a matrix are defined in terms of
<a href="https://en.wikipedia.org/wiki/Logarithm_of_a_matrix">series of matrix powers</a>.
For invertible matrices however (like SPD matrices), the matrix logarithm
and exponential can be obtained as \(\log(A) = U \log(\Lambda) U^{-1}\) and
\(\exp(A) = U \exp(\Lambda) U^{-1}\), where \(A = U \Lambda U^{-1}\).
For a given SPD matrix, we obtain the matrix logarithm by taking the
eigenvalue decomposition, taking the logarithm of the eigenvalues,
and then reconstructing the matrix.</p>

<p>While an SPD matrix has positive eigenvalues, its matrix logarithm
can have any real eigenvalues. In fact, the matrix logarithm of an SPD
matrix will be a symmetric matrix.
Thus, the matrix logarithm function maps from the SPD matrices space
to the simple vector space of symmetric matrices. Conversely,
the matrix exponential maps from the symmetric matrices
to the non-linear space of SPD matrices.</p>

<p>From the above, we see that we can parametrize SPD matrices in terms of
the unconstrained space of the lower-triangular part of symmetric
matrices, with \(f\) being the matrix exponential, and its
right-inverse being the matrix logarithm. Let’s implement this
in Pytorch:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### IMPLEMENTATION OF MATRIX LOGARITHM PARAMETRIZATION
</span>
<span class="kn">import</span> <span class="nn">scipy</span> <span class="c1"># Scipy has the matrix logarithm function
</span>
<span class="c1"># Define positive scalar constraint
</span><span class="k">def</span> <span class="nf">symmetric</span><span class="p">(</span><span class="n">X</span><span class="p">):</span>
    <span class="c1"># Use upper triangular part to construct symmetric matrix
</span>    <span class="k">return</span> <span class="n">X</span><span class="p">.</span><span class="n">triu</span><span class="p">()</span> <span class="o">+</span> <span class="n">X</span><span class="p">.</span><span class="n">triu</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="n">transpose</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>

<span class="k">class</span> <span class="nc">SPDMatrixExp</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">):</span>
        <span class="c1"># Make symmetric matrix and exponentiate
</span>        <span class="n">SPD</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">matrix_exp</span><span class="p">(</span><span class="n">symmetric</span><span class="p">(</span><span class="n">X</span><span class="p">))</span>
        <span class="k">return</span> <span class="n">SPD</span>

    <span class="k">def</span> <span class="nf">right_inverse</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">SPD</span><span class="p">):</span>
        <span class="c1"># Take logarithm of matrix
</span>        <span class="n">symmetric</span> <span class="o">=</span> <span class="n">scipy</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">logm</span><span class="p">(</span><span class="n">SPD</span><span class="p">.</span><span class="n">numpy</span><span class="p">())</span>
        <span class="n">X</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">triu</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="n">symmetric</span><span class="p">))</span>
        <span class="n">X</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">as_tensor</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">dtype</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">X</span>
</code></pre></div></div>

<h2 id="optimization-on-manifolds">Optimization on manifolds</h2>

<p>Many constrained optimization problems can be seen as
optimization on manifolds, particularly
<a href="https://sites.uclouvain.be/absil/amsbook/">matrix manifolds</a>.
The sphere and the SPD matrices examples we showed are
examples of manifolds. The reader interested in other examples
of how to use parametrizations for constrained optimization
can check the package <a href="https://github.com/lezcano/geotorch"><code class="language-plaintext highlighter-rouge">geotorch</code></a>
for optimization in manifolds, which implements several parametrizations
for manifolds in Pytorch. Curiously, the developer of <code class="language-plaintext highlighter-rouge">geotorch</code>,
Mario Lezcano, is the same person who developed the Pytorch
parametrizations tool we used in this post, and who wrote the
Parametrizations tutorial in the Pytorch documentation. Thank you
Mario for making time to chat about manifolds with me!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:4" role="doc-endnote">
      <p>Note that actually, the function \(f(\eta) = \eta/ \| \eta \|\) does not map from \(\mathbb{R}^m\) to the unit circle, because it is not defined at 0. However, in practice this is not generally a problem, as $\eta$ will not generally be driven towards 0 in this setup. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:1" role="doc-endnote">
      <p>Of interest, the distribution on the sphere obtained by projecting a Gaussian distribution is known as the Angular Gaussian or Projected Normal distribution. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2" role="doc-endnote">
      <p>Functions named <code class="language-plaintext highlighter-rouge">forward</code> are called when we call the call the model object as a function, i.e. <code class="language-plaintext highlighter-rouge">model(data)</code> <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:3" role="doc-endnote">
      <p>A Pytorch <code class="language-plaintext highlighter-rouge">nn.Module</code> is a Pytorch class that has several methods that are useful for defining and optimizing models. By creating our class with the call <code class="language-plaintext highlighter-rouge">class MyClass(nn.Module):</code>, the class inherits these methods from <code class="language-plaintext highlighter-rouge">nn.Module</code>. These then come in handy for example to define parameters with <code class="language-plaintext highlighter-rouge">nn.Parameter</code>, or to pass the model to an optimizer. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:5" role="doc-endnote">
      <p>SPD matrices are symmetric matrices whose eigenvalues are all positive. There is no simple condition on the matrix entries to ensure that it is SPD. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:6" role="doc-endnote">
      <p>Note that although \(M\) is a matrix, it is equivalent to an unconstrained vector in \(\mathbb{R}^{\frac{n(n+1)}{2}}\) having its lower-triangular elements, and it is unconstrained <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Pytorch" /><category term="Machine Learning" /><category term="Optimization" /><category term="Constrained Optimization" /><category term="Manifolds" /><summary type="html"><![CDATA[Often times, we want to optimize some model parameter while keeping it constrained. For example, we might want a parameter vector to have unit norm, a set of vectors to be orthogonal with respect to each other, or a matrix to be symmetric positive definite (SPD). For the specific cases where the constraint is for the parameter to be on a manifold, a common approach is to use Riemannian optimization. However, there is a simpler and often more efficient way to do constrained optimization: we can use a technique called parametrization.]]></summary></entry><entry><title type="html">Is the world as we see it?</title><link href="https://dherrera1911.github.io/posts/2024/08/is-the-world/" rel="alternate" type="text/html" title="Is the world as we see it?" /><published>2024-08-12T00:00:00-07:00</published><updated>2024-08-12T00:00:00-07:00</updated><id>https://dherrera1911.github.io/posts/2024/08/is-the-world</id><content type="html" xml:base="https://dherrera1911.github.io/posts/2024/08/is-the-world/"><![CDATA[<p>In this blogpost, we ask a question ancient as philosophy itself:
is the world as we see it? However we approach this question
from the view of biology and neuroscience. We discuss three
different perspectives on why the world is or is not as we see it:
An evolutionary perspective, a resource optimization perspective, and
the perspective of ambiguity of sensory information.
With this question as guiding thread, this post introduces several
major concepts of perception and neuroscience to a general audience.</p>

<p>This blogpost is a translation of a chapter I wrote for the book
<a href="https://pmb.parlamento.gub.uy/pmb/opac_css/index.php?lvl=notice_display&amp;id=102927">“Hitos y Mitos del Cerebro”</a>,
which is used in the homonymous first year undergraduate course at
Universidad de la República, Uruguay. For the pdf of the
original article in Spanish, 
<a href="/files/teaching/hym/capitulo_hym_dh.pdf">click here</a>.</p>

<h2 id="introduction">Introduction</h2>

<p>Approximately 2,400 years ago, Plato’s Republic was published
in ancient Greece, containing his famous allegory of the
cave (<strong>Figure 1</strong>). This
allegory describes a group of people living inside a dark cave, where
the only visible things are the shadows projected from the entrance of the
cave onto one of its walls. Since they cannot see anything else,
these people think that the world is made up of shadows and
cannot conceive the world as we perceive it. This allegory makes us
question the possibility that we might be in a situation similar to these
people, and that our perception presents us with a distorted world.
Thus, Plato raises the central question of this chapter: is the world
as we see it? Despite its long history, philosophy has not
abandoned this question, which, with its variants and other colorful examples
like Descartes’ demon from 1641, or Putnam’s brains in vats has
been one of the main guiding questions in the history of philosophy.</p>

<figure>
  <img src="/files/blog/hym/cueva_platon.jpeg" alt="Plato" title="Plato" width="50%" />
   <figcaption> <strong>Figure 1:</strong> Plato's cave allegory </figcaption>
</figure>

<p>Although this question might seem simple,
it can be interpreted and analyzed
in many different ways. Therefore, to answer it, we
must clarify what we mean by the words “see” and 
“world”. For example, if we take “see” as everything
we can know with our senses and through science, we are
asking whether there is a “world” that escapes us and that
we cannot know by any method (or if we live in some
sort of cave from which we cannot escape). This question
would fall within the field of philosophy. On the other hand,
if we take “see” as what we can perceive with our
senses and “world” as the physical world,
then we are faced with a question for physics. This question has
a clear answer: the world is not as we see it.
There are subatomic particles, dark matter, magnetic fields,
and many other physical entities that escape our sensory organs.</p>

<p>In contrast to those other approaches, in this chapter,
we will analyze the question from a biological and neuroscientific
point of view. The version of the question we will study
can then be posed as follows: is what we see with our senses
an accurate image of the world at our physical scale?</p>

<h2 id="what-is-seeing-visual-system-general-overview">What is seeing? Visual system general overview</h2>

<p>Before we begin to analyze what biology says, it is necessary to
reflect on what it is to <em>see</em>. Because our visual system
provides us with an excellent ability to see so
effortlessly, it difficult for us
to appreciate the complexity of the task. To gain intuition about how
complicated seeing is, let us consider a simple system: a robot
with vision.</p>

<p>Suppose we have a small robot, and we
want to make it <em>see</em>. The first thing that we need to
do is give it a detector that receives light, or the equivalent
of an eye. This could be simply a digital video camera.
This camera receives the image of the scene and
transforms it into a matrix of
numbers, indicating the intensity of light at each point.
This matrix of numbers constitutes a digital image,
where each number represents a pixel (<strong>Figure 2</strong>).</p>

<p>But just taking pictures is not enough.
Seeing involves extracting relevant information about the world
from the pictures (e.g., what object is in front of me; how far away is it;
what size is it, etc.). To do this, we must give the robot
a computer with a program that extracts this information from
these matrices of numbers.
It is not difficult to realize how complex such a program
needs to be to allow the robot to convert those
number matrices into assessments like:
“I have a medium-height marble table two meters in front
of me.” For example, note that the numerical matrix (i.e., the raw image)
can change radically if we modify the light source, move the robot
slightly, change the background of the image, or rotate the table.
The program faces the difficulty
of giving the same result despite all those “trivial” changes.
These difficulties can be seen in how difficult it is
to develop fully self-driving cars despite the immense
interest in this technology.
Science and engineering have not yet solved this problem that
our brain solves so effortlessly.</p>

<figure>
  <img src="/files/blog/hym/imA.png" alt="Foxy" title="Computation vision" width="70%" />
   <figcaption> <strong>Figure 2:</strong> A digital image can be thought of as a matrix of numbers. On the left, we see a black-and-white image and an enlargement of a segment of it. On the right, we see the same image segment, but with numbers between 0 and 100 indicating the intensity value of each pixel. A computer program that "sees" must take that matrix of numbers and extract information from the scene (e.g., that it contains a fox) from it.</figcaption>
</figure>

<p>What is interesting about the robot example is that our visual system is
not fundamentally different. Light enters our eyes and forms an image on
the retina (located at the back of the eye). The retina
is covered with light-sensitive cells called photoreceptors. Photoreceptors are active
when they do not receive light and become inactive when illuminated. In
this way, a photoreceptor with greater activation indicates a dark region of
the image, and one with less activation indicates a lighter region,
thus representing the image through neural activity. This system, by which
the degree of illumination is sensed, can then be thought of as
the one described earlier, with each photoreceptor acting like an individual pixel
and its degree of activation as the numerical value in the matrix that
is the image.</p>

<p>Then, just like the robot, the
image must be processed to extract the relevant information. In our visual
system, after initial processing in the retina, the optic nerve transmits
visual information to the brain, where we have dozens of cortical areas
dedicated to extracting information about the world. These areas are densely
interconnected, and their functions are still not fully understood, but they carry
out the processing that allows us to recognize a face, estimate a
distance to throw a projectile, or choose the apple we like the
most from the store. This processing occurs subconsciously, and we do
not have access to it, but it is important to remember that
“seeing” constitutes a complex processing and interpretation of the image by
the brain.</p>

<p>Having established the complexity behind our ability to see,
we are in a position to begin answering the question, that
is, whether the processing our brain does of the image results in
a faithful description of the world around us. To do this,
we will analyze the question from three perspectives: 1) the
evolutionary perspective; 2) the cost of processing information perspective,
and 3) the ambiguity in the data perspective.</p>

<h2 id="evolutionary-perspective-the-visual-system-did-not-evolve-to-see-the-world-as-it-is">Evolutionary Perspective: The Visual System Did Not Evolve to See the World as It Is</h2>

<p>Like the rest of the systems and biological processes that make up our
organisms, the visual system evolved to contribute to our reproductive success.
That is, evolution did not necessarily select for the visual system that best
represents the environment, but rather the one that most favored survival and
reproduction. An example that illustrates this point is our color vision,
which went through an interesting path to reach its current state.</p>

<figure>
  <img src="/files/blog/hym/imB-pre.jpg" alt="Waves" title="Color wavelength" width="50%" />
   <figcaption> <strong>Figure 3:</strong> Color vision corresponds to
  the ability to distinguish the wavelength of light. Image 
  taken from
  <a href="https://www.sciencelearn.org.nz/resources/47-colours-of-light">https://www.sciencelearn.org.nz/resources/47-colours-of-light</a>.</figcaption>
</figure>

<p>Light is an electromagnetic wave, and like other waves, it is
characterized in part by its wavelength (<strong>Figure 3</strong>).
Color vision is the ability to
distinguish the wavelength of the light we perceive. How is this achieved?
To begin with, a particular photoreceptor has a preference for certain
wavelengths that can more easily modify its activation (<strong>Figure 4</strong>).
But despite this preference, a single photoreceptor does not allow us
to distinguish between different wavelengths, because its
activation also depends on the intensity of the light.
This means that a given photoreceptor activation
can represent a high intensity of a non-preferred wavelength,
or a low intensity of a preferred wavelength.
On the other hand, two photoreceptors with
different wavelength preferences do allow us to discriminate between
wavelengths, letting us separate the contribution
of light intensity and wavelength. Having more
photoreceptors with different preferences allows an
organism to better discriminate wavelengths, improving color vision.</p>

<figure>
  <img src="/files/blog/hym/imB.jpg" alt="Waves" title="Photoreceptors" width="70%" />
   <figcaption> <strong>Figure 4:</strong> 
  Humans have three different photoreceptors for color vision, called cones, and each has its preference for different wavelengths. The graph shows the ability of each cone (named blue, green, and red) to absorb different wavelengths (whose color is indicated in the bar below). A single cone does not allow for disambiguation of wavelength because its activation depends on the color of the light and its intensity (e.g., it cannot determine if greater activation is due to a change in light color or a change in its intensity).</figcaption>
</figure>

<p>At one point in evolution, the subphylum of vertebrates came to
have four different types of color photoreceptors.
But then at another point in our evolution,
mammals lost two of these photoreceptors (this was when we
were nocturnal, and color vision was not as important for us),
leaving them with only (or dichromatic vision). At a later point,
however, primates acquired a new photoreceptor,
bringing our total to three (or trichromatic color vision).
This photoreceptor falls in the middle of our visible color
spectrum, and makes
it easier to discriminate green from other colors. The main hypothesis about
what led this photoreceptor to be selected is that it
was particularly favorable for better perceiving vegetation,
for example, the fruits that were part of our diet against the
green background of trees.</p>

<p>These two evolutionary events, the loss and re-gain of photoreceptors,
show how evolution can select for improvements in sensory systems
representation of the world, but that it can also lead to
its deterioration. In this way, evolution has
led us to currently perceive fewer colors than many species
of birds that still maintain the four original
vertebrate photoreceptors.</p>

<figure>
  <img src="/files/blog/hym/imB-post.jpg" alt="Waves" title="Color evolution" width="50%" />
   <figcaption> <strong>Figure 5:</strong> Two color photoreceptors
  were lost by mammals in evolution, making our color vision worse.
  We humans acquired a new photoreceptor making us thrichromats, as
  opposed to most other mammals. Image by Jen Christiansen, taken
  from "What Birds See" by Timothy H. Goldsmith, Scientific American
  July 2006.</figcaption>
</figure>

<p>Another interesting example of the relationship
between evolution and perception is the study of the frog’s visual
system presented in the article
<a href="https://ieeexplore.ieee.org/stamp/stamp.jsp?arnumber=4065609">“What the Frog’s Eye Tells the Frog’s Brain”</a> by Lettvin
et al., published in 1959. The experiment
carried out in this article involves showing visual stimuli to a frog while
recording the activity of the fibers of its optic nerve (which take
the information from the eye to the brain) to determine
what information about the
scene these fibers transmit to the brain. One interesting
thing about this article is that the authors discuss the
results in the context of the
natural behavior of the organism, which uses vision to hunt and escape
predators. For example, the frog’s visual behavior has the
peculiarity that it seems not to perceive the static elements of the world
around it (e.g., food does not catch its attention
if it does not move, <a href="https://www.youtube.com/watch?v=boytEUqImMI&amp;t=25s">see video</a>).
Moreover, its predatory behavior is mainly
guided by the movement and size of visual objects: it will try
to capture any small object that moves like an insect, even if
it looks very different from an insect to us.</p>

<p>The authors discuss that this behavior fits
very well with their findings on fiber activation.
They describe four types of fibers in the optic
nerve, each reporting about very specific visual patterns in the
environment. For example, one of the types of fibers described
by the authors is activated when a
small shadow stops at a specific point in the visual field
and moves intermittently.
This type of fiber seems to correspond to the behavior of the
frog described above, responding to the movement of small objects
that move like insects. Therefore, the brain receives
highly pre-processed information 
where a large part of the visual world detail is discarded,
which may help explain why the frog’s visual behavior is
so limited. The frog will then be able to
detect this type of visual event, but not others for which it
does not have fibers.</p>

<p>But, although the frog’s
perception may seem limited to us, what is important is that it
allows it to generate the necessary behaviors to survive and reproduce. For
example, concerning the fiber described earlier, the authors ask: “<em>can
one describe a better system for detecting an accessible insect?</em>” So,
although the frog’s visual world is limited (for it is
composed of some simple patterns like moving dots), it is sufficient for
its ecological needs. Although it is easy to fool a frog in
the laboratory with something that looks like an insect, the relevant
question is: how many objects that are not insects have that size and
move like them in its natural environment?</p>

<p>It is possible to notice a similarity between the frog case
and the allegory of the cave, and although our visual system
is much more complex, it is natural to ask now: to what
extent do we suffer from limitations as great as those of the frog?
In a certain way, our visual system carries out a similar
process: the visual stimulus is already processed even in the retina,
and in each visual area of the cerebral cortex, specific stimulus patterns
are extracted and passed on to higher areas, and those were
selected because of their contribution to our reproductive
success. In this process, information about the world is
also lost. For example, if we look at two different
images of white noise (the static present in old televisions,
<strong>Figure 6</strong>),
these images are very different considered pixel by pixel, but
they look identical to our visual system, which discards
a lot of this information.</p>

<figure>
  <img src="/files/blog/hym/noise1.png" alt="Noise" title="Noise" width="50%" />
   <figcaption> <strong>Figure 6:</strong> Two different
    white noise images, that although they are very different pixel-by-pixel,
    look identical to our visual system.</figcaption>
</figure>

<p>However, there are
reasons to think that our visual system is not as affected as that
of the frog. For example, the patterns detected in the early
stages of our visual system (point-like structures in the retina;
stripe-like structures in the primary visual cortex) are the
same as those reached by mathematical tools that seek the best method of
representing images. This suggests that perhaps we are capturing a large part
of the information available in images.
This observation also aligns with the argument that
flexible goals and behavior such as that in higher mammals
requires faithful representation of the world (e.g. see
a technical analysis <a href="https://onlinelibrary.wiley.com/doi/full/10.1111/cogs.13195">here</a>).
Thus, it could be expected that
evolution has led us to see the world approximately as it is.</p>

<p>In summary, our visual system has been shaped by evolution
to perceive those aspects that are important for our survival.
However, our complex and adaptable behavior would seem
to require the ability to faithfully perceive our environment, and some
computational studies support this reasoning. Although there are aspects of
the world that we do not perceive, the evolutionary perspective leaves open
the possibility that we perceive the world accurately.</p>

<h2 id="perspective-on-the-cost-of-representation-representing-the-world-faithfully-is-expensive">Perspective on the Cost of Representation: Representing the World Faithfully Is Expensive</h2>

<p>Our brain is finite and has limited resources. Capturing and processing visual
stimuli is costly, so our ability to perceive the world faithfully is
also limited. However, evolution tends to lead to the efficient use
of resources, and it happens that our visual system manages them
in very efficient ways, allowing us to perceive correctly what is relevant to us.
An important and illustrative aspect of resource use by our visual system is the
separation between central and peripheral vision, which we describe below.</p>

<p>It is evident that vision plays a fundamental role in our cognition,
which can be expressed by saying that we are a “visual animal.”
In line with this, the brain dedicates a significant amount of
resources to visual processing. But these resources are not distributed evenly to
process the entire visual field; instead, their distribution marks a very
strong distinction between two components of vision: central vision
(corresponding to the part of the retina called fovea) and
peripheral vision. These components are evident to us through introspection:
while we perceive very clearly the part of the visual scene on
which we focus our gaze, what falls in our peripheral vision is much less clear.
An obvious example of this is that it is impossible for us to
perform some tasks (like reading) with peripheral vision.</p>

<figure>
  <img src="/files/blog/hym/imC.png" alt="fovea" title="Fovea" width="50%" />
   <figcaption> <strong>Figure 7:</strong> 
  The resources of the visual system are not distributed homogeneously across our entire visual field. The image shows a map between the visual field (on the left) and the primary visual cortex (on the right). We see that nearly half of the primary visual cortex is dedicated to processing a small fraction of the visual field called the fovea.
  </figcaption>
</figure>

<p>This division between central and peripheral vision is so natural to us that we
don’t usually question it, but why do we see with
little clarity in the periphery?; would it be possible to have the
high resolution of central vision across the entire visual field? A key
to understanding this is that although our central vision occupies a small fraction
of the visual field (we can roughly define it as the visual area
covered by our closed fist with an extended arm; <strong>Figure 7</strong>), it
uses approximately half of the resources of our visual system.
Thus, although peripheral vision occupies the majority of the visual field,
it uses only the other half of the cortical resources. If
we extrapolate the relationship between visual field and cortical area from central
vision, processing our entire visual field with the sharpness of central vision would
require a much larger brain than we have, which seems biologically
impossible.</p>

<p>But even though we clearly see
only a small part of the visual field, we use an important
tool to make the most of the resources available: eye movements.
We are constantly moving our eyes (and head) to capture the
important elements of the environment with high precision. This scanning of the
scene is something we know how to do so well that it feels
natural and simple, and we don’t notice our constant eye
movements. But our eyes are always subconsciously choosing
for the most relevant aspects of the scenes to
scan, allowing us to obtain large amounts of information with limited resources.
With this efficient allocation of resources, we manage to attain
a feeling of a detailed perception. But despite the efficiency of the system,
we often overestimate our perception, as the following examples illustrate.</p>

<p>The phenomenon called change blindness is demonstrated experimentally
by flashing in an alternating fashion two identical images
that differ in some specific element
(<a href="https://www.youtube.com/watch?v=FWVDi4aKC-M">see YouTube example</a>).
The experiment consists of the participant identifying
what the differences between the images are. What is interesting about the
phenomenon is that even large differences between the images are
difficult to identify. This result contrasts with our subjective impression:
although we believe we perceive the image clearly,
the reality is that it is difficult for
us to remember even its most conspicuous elements.</p>

<p>Another example is inattentional blindness. This phenomenon
consists of certain elements of images to which
we are not paying attention going unnoticed, even though they are very
visible. An iconic example
(<a href="https://www.youtube.com/watch?v=UfA3ivLK_tE">see here</a> before
you continue reading) consists
of a video showing a group of people passing a basketball, and
the task is to count how many passes are made. At the
end of the video, it is revealed that a curious animal made
an undisguised appearance that goes unnoticed to the viewer
concentrated on counting the passes.</p>

<p>Another example, striking because of the participants expertise,
is an experiment in which radiologists were asked to make
a diagnosis on some tomography plates in which a visible image of a
gorilla had been included (<strong>Figure 8</strong>). Although the radiologists
examined the image closely, and almost all laid their eyes on the
gorilla (as measured by the eye-tracking technique), when asked
at the end of the experiment, most had not noticed the hidden
ape. This last example also shows that it is not enough to
have something in central vision to process it correctly. The allocation of
resources in the visual system does not only occur in eye movements but
also at the level of information processing, prioritizing one processing “pathway”
over another.</p>

<figure>
  <img src="/files/blog/hym/imD.png" alt="Gorilla" title="Gorilla" width="50%" />
   <figcaption> <strong>Figure 8:</strong> 
  Inattentional blindness is a phenomenon where aspects of the visual field that we do not expect or are not paying attention to go unnoticed. The image shows a classic experiment where a gorilla was inserted into tomography plates, as shown in the enlargement. In the experiment, radiologists were asked to evaluate the tomography without being informed of the gorilla's presence. Most radiologists did not detect the presence of the gorilla, despite many of them fixing their gaze on it (the white circles on the right show the eye movements of one of the radiologists). Image taken with permission from 
  <a href="https://journals.sagepub.com/doi/full/10.1177/0956797613479386?">Drew, Vo &amp; Wolfe (2013) Psychol. Sci. 9. 1848.</a>
  </figcaption>
</figure>

<p>These examples illustrate how costly it is to
process visual stimuli and how, at a given moment, we perceive
only a small part of the scene with clarity. But is this
something new? Before starting this section, any of us already knew
that there are elements of the scene that we cannot perceive at a
given moment, for example, what is behind us. Although it
is interesting how our “real” or “objective” perception seems
worse than we believe, this new discovery does not seem to close
the issue since, from the beginning, we know that our visual
system has limitations.</p>

<p>Moreover, these limitations do not imply that we
cannot perceive well the specific elements of the world on which we focus
our attention, just as not having eyes on the back of our
heads does not mean we cannot turn around to see what is behind
us. And although the examples discussed are striking, it is important
to note that the real world is very different from the experimental manipulations
that show these effects (e.g., in the real world, objects do not
suddenly disappear as in change blindness experiments). From
this, the next natural question is: the part of the scene
on which we focus our attention and which we observe closely, is
it as we see it?</p>

<h2 id="perspective-on-the-ambiguity-of-the-data-seeing-requires-interpreting-the-world">Perspective on the Ambiguity of the Data: Seeing Requires “Interpreting” the World</h2>

<p>The last aspect that we will consider 
is a fundamental aspect of the computational tasks of
vision: visual stimuli are ambiguous, so we must interpret
them to obtain information about the world.
This stems in part from the fact that
our visual input is two-dimensional (the plane of our retina, like the
two dimensions of a photograph), while the external world
has three spatial dimensions. Our visual system is very good at making
(subconscious) interpretations of visual stimuli, making it difficult for us
to see how they can be ambiguous, but nonetheless these necessary
interpretations occur at various stages of visual processing.</p>

<figure>
  <img src="/files/blog/hym/imE.jpeg" alt="Daltmatian" title="Daltmatian" width="50%" />
   <figcaption> <strong>Figure 9:</strong> 
  This figure shows a classic image of a scene that has been converted to black and white, resulting in the appearance of a set of scattered black spots. But despite the chaotic image, our brain interprets this set of spots to see the three-dimensional scene of a Dalmatian walking towards a tree.
  </figcaption>
</figure>

<p>For example, one of the first steps in visual processing
is to group the pixels into segments or surfaces (a process we call 
image segmentation”). This involves using some criterion to determine
when two regions of the image belong to the same surface. But this is not
easy. We can think of some simple rules to group image
sections together (e.g., group pixels that have similar color and
are close), but these simple rules will quickly fail in many
real world cases. Our brain manages to group the elements
of the images using complex rules that we still do not fully
understand. For example, <strong>Figure 9</strong> shows a classic scene
with black spots spread across an image with very coarse
information. But despite the chaotic appearance of the image, our brain
manages to group the dots together into surfaces, group these into
objects, and finally into a scene. Looking at how chaotic
the image is, it is difficult to understand what evidence our
brain uses to interpret that chaos of pixels.
But beyond the details of processing, what matters here is that
our brain is having to interpret many aspects of the image
(such as what areas of the image are from the same surface),
and that in general many interpretations are possible and
our brain must choose one. The next example illustrates
how this ambiguity in possible interpretations can lead to
illusions.</p>

<figure>
  <img src="/files/blog/hym/imE2.jpg" alt="Ames" title="Ames" width="50%" />
   <figcaption> <strong>Figure 10:</strong> 
  The Ames room is a famous illusion that can occur in the real world, and
  is used in science shows and fairs. Using a room with irregular
  geometry, the illusion makes a person look tiny or giant, depending
  on their position in the room.
  </figcaption>
</figure>

<p>The Ames room is a classic illusion in which we generate a misinterpretation of
a scene. This is a room with a particular shape: it
is not rectangular, and both its walls and ceiling and floor are
sloped and not parallel (<strong>Figure 10</strong>). To create the illusion, one of its
walls has a carefully placed hole through which we can look and perceive
a normal rectangular room (with parallel walls, ceiling, and floor).
When we look through the hole, our perception is wrong,
as we believe we see a normal room when, in fact,
we have a distorted room in front of us. Moreover, if
there are people in the room, depending on their position, we
will perceive them as giants or as tiny
(see a video <a href="https://www.zmescience.com/feature-post/health/mind-brain/optical-illusion-ames-room/">here</a>). The important thing to
note here is that the visual stimulus in this case (and in
all others) is ambiguous. It is compatible with both a rectangular
room (as we mistakenly perceive) and the actual scene of the
irregular room (since this is, in fact, what generates the
image). In fact, the visual stimulus is compatible with infinite possible
scenes. For example, it could be a giant room with giant
people, a small room with small people, or it could have
many different shapes with people of various sizes. The image is compatible
with all these alternatives because all of them could generate the same pattern
of light in our retinas, and interpreting the image involves choosing one
of these alternatives.</p>

<p>This example show that our visual system makes interpretations of visual
stimuli to perceive the scene around us, and that
these interpretations can be wrong. But how does our brain
arrive at these specific interpretations? Although this question is
still an area of research, neuroscience can give us some answers. First, it
is important to clarify that to investigate this, we normally study simplified
stimuli that aim to understand the processing of a very specific type
of information. Some examples could be how we use color to segment
images or how we use visual textures to estimate distances. Despite the
gap between these simplified experiments and our “natural” perception, they
allow us to draw two relevant conclusions to our question: 1)
the visual system uses a set of “rules” that allow us
to construct the interpretation of the image, and 2) these rules
are not arbitrary but are based on the structure of the world we
inhabit.</p>

<p>In the process of choosing which of the possible scenes
is the one that generates the image, the brain imposes restrictions 
(or rules) on the interpretation that helps select some possibilities and discard
others. These rules mark how to interpret certain elements of the image
and are applied at several levels and in parallel. For example,
a phenomenon observed with simple stimuli is that our vision groups the elements
of the image that are aligned in the direction of their orientation,
that is, they are are collinear (<strong>Figure 11</strong>). On the contrary,
elements that are aligned in the direction perpendicular to their orientation do not
tend to be grouped. Then, using this rule to interpret natural
images, our visual system can choose the interpretation of the scene that
groups the elements that are collinear and discard other interpretations.</p>

<figure>
  <img src="/files/blog/hym/imF.png" alt="Gabor" title="Gabor" width="50%" />
   <figcaption> <strong>Figure 11:</strong> 
  The grouping of image elements is a fundamental process in visual perception, occurring according to complex rules that we still do not fully understand. The image on the left shows an example of grouping, where our visual system groups the collinear elements despite being surrounded by other similar elements. In the image on the right, we have the same elements but orthogonal to the alignment direction, and our visual system does not group them.
  </figcaption>
</figure>

<p>Another rule that the visual system uses is that it tends to assume that the
light source comes from above. A common ambiguity is that the same
image can be generated by a concave surface illuminated from above and by
a convex surface illuminated from below, and vice versa. This ambiguity
and our brains preference for light from above is why
when seeing an image of a crater upside down, it looks like a
hill (<strong>Figure 12</strong>).
In the face of this type of ambiguity, our
visual system will tend to choose the scene where the light comes from
above and then interpret whether the surface is concave or convex according to
this criterion. Thus, by applying a large number of rules,
the visual system can choose one interpretation over another at different
levels until reaching a global interpretation of the image we receive, which
will be our perception. But where do these rules come from?; how do
we know if they are good for perceiving the world?</p>

<figure>
  <img src="/files/blog/hym/crater.png" alt="Gabor" title="Gabor" width="50%" />
   <figcaption> <strong>Figure 12:</strong> 
  A crater image turned upside down looks like a hill, showing how
  the same image can be generated by different scenes.
  </figcaption>
</figure>

<p>Theoretical neuroscience research suggests that these rules
we use to interpret images are based on the regularities
of the world around us. Our world has a
marked structure that gives rise to many regularities, that is, patterns
that repeat in images and have predictable relationships with the world. For
example, our visual environment is mostly composed of solid objects. Due
to the laws of physics, objects generate continuous contours in images,
consisting of collinear edges. This could explain the rule of
grouping collinear elements as a reasonable
consequence of the world’s structure: objects in the world tend
to generate collinear elements, and it would be correct to group them
to form a more global structure.</p>

<p>Similarly, it is easy to notice that in their vast majority,
light sources in our natural environment come from
above, so it makes sense to use a rule
that chooses scene interpretations with this characteristic. Thus, the rules used
by our visual system would be associated with the structure of the world
around us and would help choose the image interpretations that best match it.</p>

<p>But, if these rules are derived from the world’s structure to represent
it correctly, why do we perceive the Ames room and other illusions
incorrectly? The answer is that they
are atypical stimuli that violate the normal structure of the world. The
image of the Ames room is generated by a specific perspective on a
room with a very specific irregular shape, but this is a configuration
that is unlikely to occur in the real world. It is much
more likely that an image of that type is generated by a rectangular
room with its parallel walls, ceiling, and floor.
Although these illusions
show the brain’s interpretations at work, they occur because of
the use of contrived stimuli that are unlikely to be found in the
real world.</p>

<p>In conclusion, images are fundamentally ambiguous, and
a given image is compatible with infinite possible scenes, so perceiving involves
a process of interpretation. But this process is not arbitrary, as it responds
to rules that fit well with the world’s structure, ensuring
that our interpretations are generally good.</p>

<h2 id="so-what-is-the-answer-to-our-question">So, What Is the Answer to Our Question?</h2>

<p>We have seen three neurobiological reasons why the world might not be as
we see it: 1) our visual system did not evolve to
allow us to see the world as it is, but to guide
useful actions; 2) representing the world as it is is very
expensive, and 3) the stimuli we receive are ambiguous and require
significant interpretation on our part. In all three cases, we saw
examples that might suggest that the world is not as we see it.
But we also discussed reasons why these limitations are not
determinative: 1) we have very complex behavior that may require a faithful perception
of the world (and available computational research seems to say that at least
our basic neural processing is good); 2) we allocate our processing
resources very well, and although a significant part of the world escapes us (which
is inevitable), we build a decent image of it that we are attending to,
and 3) our interpretations
use rules that are based on the structure of the
world around us, giving them a solid foundation.</p>

<p>Ultimately, the answer to whether the world is as we see it will depend on
the precise definition of that question and the standards we have for what
constitutes “seeing the world as it is.” And if it is
disappointing not to have a concrete answer at the end of the chapter,
perhaps it helps to remember that, despite its simple appearance,
this question has sparked (and continues to spark) discussion throughout a
significant part of the history of ideas.</p>

<p>To conclude, it is worth highlighting that although we marked a distinction
between the philosophical question and the neuroscientific or cognitive one at the beginning
of the chapter, philosophy is an integral part of cognitive sciences and
neuroscience. Many of the questions studied by the latter are based on
concepts that, when deeply examined, lead to analyses and discussions that
currently belong to philosophy. Even though we did not explicitly highlight it,
many points we took for granted in this chapter align with one
or another philosophical perspective and can be harshly criticized from
other philosophical perspectives (e.g., it is debatable whether the visual stimuli we
receive are truly ambiguous, or whether our visual system constructs a
3D model of the world around us). Perhaps a good way to close the chapter
is by revisiting Daniel Dennett (an important philosopher of science), who
eloquently expresses this idea: “There is no such thing as science
free of philosophy, there is only science whose philosophical baggage is carried
on board without examination.”</p>]]></content><author><name>Daniel Herrera-Esposito</name><email>dherrera1911@gmail.com</email></author><category term="Perception" /><category term="Biology" /><category term="Philosophy" /><category term="General audience" /><summary type="html"><![CDATA[In this blogpost, we ask a question ancient as philosophy itself: is the world as we see it? However we approach this question from the view of biology and neuroscience. We discuss three different perspectives on why the world is or is not as we see it: An evolutionary perspective, a resource optimization perspective, and the perspective of ambiguity of sensory information. With this question as guiding thread, this post introduces several major concepts of perception and neuroscience to a general audience.]]></summary></entry></feed>