“The Algorithm”: What Is It, Really?

If you have been in the social media space long enough, you have certainly heard about the omnipresent concept of “the algorithm”. Long story short, the truth is that there is no such thing as “the algorithm”, and what we really have on these platforms are complex (and often very diffuse) systems trying to optimize a single metric. Sometimes that metric is watch time; sometimes it is clicks; sometimes it is purchases. Only the developers of the platforms know exactly what they are trying to accomplish with the recommendation engine.

That’s why, in the end, those social media or marketing “experts” who claim to know exactly how the algorithm works are usually just very well-spoken scams. But that’s a story for another day.

However, these days X finally open-sourced the code of the system they use for the (infamous?) “For You” section. In this post, I will try to explain it to you. Not because I want to give advice on how to become viral (if you want to use these learnings for that, go ahead), but because it is a fun learning exercise.

I want to break this post into a few pieces so you can better understand some of the ideas behind it. I won’t dig deep into the engineering part of the solution, but will focus mostly on the algorithm itself, just to understand what lies at the heart of the system.

One thing I want to say before jumping into the explanation of the structure of the algorithm (and I will expand on this discussion in section 5) is that this new system is optimized solely to align with your preferences. Not to challenge them, not to open your mind; it wants you to see what you are interested in.

With that in mind, let’s get started.

1. Getting the posts to show

At the moment you access the For You section, the system quickly puts together two different pieces. First, it gathers information about the user’s activity and some features of the account itself. It then uses that information as input to retrieve candidate posts through two different approaches: posts from people you follow, and posts from people you don’t follow. Let’s dig deeper into that.

Getting User Data

In order to gather all the information from a user who is about to spend a few minutes reading posts on X, two specific actions happen at this point. First, the system extracts the sequence of your most recent actions. That is, it identifies the interactions you have had during a certain period of time and caps them to the last N events if you performed more within that time window. Then, the entire sequence is converted into a binary Protobuf format.

let mut aggregated_actions =
self.aggregator
.run(&filtered_actions, p::UAS_WINDOW_TIME_MS, 0);
// Truncate to max sequence length (keep last N items)
if aggregated_actions.len() > p::UAS_MAX_SEQUENCE_LENGTH {
let drain_count = aggregated_actions.len() - p::UAS_MAX_SEQUENCE_LENGTH;
aggregated_actions.drain(0..drain_count);
}

What kind of actions does the system track? Looking at different pieces of the code, we can infer the action types: likes (favorites), replies, reposts, quote tweets, clicks on posts, clicks on profiles, video views, photo expansions, shares (including via DMs and copy link), dwell time (how long you looked at a post), and even negative signals such as marking something as “not interested”, blocking, muting, or reporting. Each of these becomes a data point in your engagement history.

Secondly, the system retrieves user features that are not behavior-related. Concretely, the features the system extracts are the following:

pub struct UserFeatures {
pub muted_keywords: Vec<String>,
pub blocked_user_ids: Vec<i64>,
pub muted_user_ids: Vec<i64>,
pub followed_user_ids: Vec<i64>,
pub subscribed_user_ids: Vec<i64>,
}

As you can see in this section, the information about the user is pretty straightforward: what you have done within a given time window (it would be interesting to see how large this window is, but I don’t expect it to be very long), which users or topics you don’t want to see, and which users you follow.

One kind of surprisingly fact: the system does not consider at all what you write. It doesn’t matter what your style is when it comes to writing posts; it only considers your last actions.

Candidate posts data

The candidates are the posts that can potentially be shown to you. In turn, there are two main sources from which candidate posts are retrieved: posts from your network (that is, posts from people you actually follow) and out-of-network posts that might be relevant to you.

For in-network posts, the system first checks the user’s following list, applies a hard limit to its length (surely for performance reasons), and then sorts it by recency. This means that the system prioritizes posts from the accounts you follow that are more recent. Additionally, the system explicitly removes post IDs that the user has already seen within the same session.

The in-network posts come from a service called Thunder, which is an in-memory post store that ingests posts from Kafka in real time. Thunder keeps posts from all users in memory, organized by author, and can return posts from the accounts you follow. Internally, it maintains three separate stores: one for original posts, one for replies and reposts, and one specifically for video content.

Things get a lot more interesting for the out-of-network posts. At this step, the system uses all the information collected from the user that we discussed previously and calls the service phoenix_retrieval_client.retrieve(). This retrieves a new list of posts that may be relevant to you but do not come directly from users you follow. This is, in a way, the exploration step of the system.

let response = self
.phoenix_retrieval_client
.retrieve(user_id, sequence.clone(), p::PHOENIX_MAX_RESULTS)
.await
.map_err(|e| format!("PhoenixSource: {}", e))?;

Of course, there is a whole set of interesting logic behind the idea of “posts that can be relevant to you”, which we will dig deeper into in section 2.

Hidration of data

After candidates are retrieved from both sources, the system After candidates are retrieved from both sources, the system needs to “hydrate” them, which essentially means fetching additional metadata that wasn’t available during retrieval. This includes:

  • Core post data: the actual text, media attachments, and creation time
  • Author information: username, verification status, and profile information
  • Video duration: for video posts (used later in scoring)
  • Subscription status: whether the post is behind a paywall
  • Visibility information: whether the post has been flagged for any safety issues

All of these hydrators run in parallel to minimize latency. If any hydration step fails, the system logs an error but continues with whatever data it was able to fetch.

2. How to get posts from the out-of-network space?

Here’s where the real machine learning comes into play. X uses a system called Phoenix. This recommendation system has two main components: the retrieval part, which is used when fetching out-of-network candidate posts, and the ranking part, where all candidates are scored and mixed together. Let’s focus first on the retrieval part.

Retrieval system: Two Tower model

Like most modern retrieval systems, Phoenix uses a Two-Tower architecture to encode and retrieve posts. In this model, there are two different towers: the candidate tower (that is, the tower for posts) and the user tower. The final goal of this architecture is to project both representations into the same vector space, so the system can perform similarity search over candidates at inference time. Let’s dig deeper into the two towers.

Candidate Tower

The candidate tower is a pretty simple one: it is just an MLP that takes the concatenated post and author embeddings (read from a lookup embedding table), passes them through two projection layers using the SiLU activation function, and then normalizes the resulting embedding. This is the original code that implements this:

class CandidateTower(hk.Module):
"""Candidate tower that projects post+author embeddings to a shared embedding space.
This tower takes the concatenated embeddings of a post and its author,
and projects them to a normalized representation suitable for similarity search.
"""
emb_size: int
name: Optional[str] = None
def __call__(self, post_author_embedding: jax.Array) -> jax.Array:
"""Project post+author embeddings to normalized representation.
Args:
post_author_embedding: Concatenated post and author embeddings
Shape: [B, C, num_hashes, D] or [B, num_hashes, D]
Returns:
Normalized candidate representation
Shape: [B, C, D] or [B, D]
"""
if len(post_author_embedding.shape) == 4:
B, C, _, _ = post_author_embedding.shape
post_author_embedding = jnp.reshape(post_author_embedding, (B, C, -1))
else:
B, _, _ = post_author_embedding.shape
post_author_embedding = jnp.reshape(post_author_embedding, (B, -1))
embed_init = hk.initializers.VarianceScaling(1.0, mode="fan_out")
proj_1 = hk.get_parameter(
"candidate_tower_projection_1",
[post_author_embedding.shape[-1], self.emb_size * 2],
dtype=jnp.float32,
init=embed_init,
)
proj_2 = hk.get_parameter(
"candidate_tower_projection_2",
[self.emb_size * 2, self.emb_size],
dtype=jnp.float32,
init=embed_init,
)
hidden = jnp.dot(post_author_embedding.astype(proj_1.dtype), proj_1)
hidden = jax.nn.silu(hidden)
candidate_embeddings = jnp.dot(hidden.astype(proj_2.dtype), proj_2)
candidate_norm_sq = jnp.sum(candidate_embeddings**2, axis=-1, keepdims=True)
candidate_norm = jnp.sqrt(jnp.maximum(candidate_norm_sq, EPS))
candidate_representation = candidate_embeddings / candidate_norm
return candidate_representation.astype(post_author_embedding.dtype)

That sounds great, but… how do they get the post and author embeddings? Well, there are, again, two independent pieces here. Essentially, both posts and authors are represented using hash-based embeddings. Instead of having a unique embedding for each post ID or author ID (which would be impossible to scale given the billions of posts and users), the system uses multiple hash functions to map IDs to positions in embedding lookup tables.

But where do the actual embedding values come from? This is a bit obscure, to be honest. The Phoenix model expects embeddings to already be looked up before being passed in. That means there is separate infrastructure that stores massive embedding tables and serves lookups, and we don’t really know exactly how those embeddings are computed.

User Tower

The User Tower is significantly more complex. Instead of a simple neural network, it uses the same transformer architecture that powers Grok. Yes: the system that decides which posts you see uses the same underlying technology as the model you usually see answering questions in posts.

As you can imagine, the user tower uses all the information we discussed in section 1 — that is, the user features and the sequence of recent actions. All of this information is then concatenated and projected into a unified representation for each history item:

def block_history_reduce(
history_post_hashes: jnp.ndarray,
history_post_embeddings: jnp.ndarray,
history_author_embeddings: jnp.ndarray,
history_product_surface_embeddings: jnp.ndarray,
history_actions_embeddings: jnp.ndarray,
num_item_hashes: int,
num_author_hashes: int,
embed_init_scale: float = 1.0,
) -> Tuple[jax.Array, jax.Array]:
"""Combine history embeddings (post, author, actions, product_surface) into sequence.
Args:
history_post_hashes: [B, S, num_item_hashes]
history_post_embeddings: [B, S, num_item_hashes, D]
history_author_embeddings: [B, S, num_author_hashes, D]
history_product_surface_embeddings: [B, S, D]
history_actions_embeddings: [B, S, D]
num_item_hashes: number of hash functions for items
num_author_hashes: number of hash functions for authors
emb_size: embedding dimension D
embed_init_scale: initialization scale
Returns:
history_embeddings: [B, S, D]
history_padding_mask: [B, S]
"""
B, S, _, D = history_post_embeddings.shape
history_post_embeddings_reshaped = history_post_embeddings.reshape((B, S, num_item_hashes * D))
history_author_embeddings_reshaped = history_author_embeddings.reshape(
(B, S, num_author_hashes * D)
)
post_author_embedding = jnp.concatenate(
[
history_post_embeddings_reshaped,
history_author_embeddings_reshaped,
history_actions_embeddings,
history_product_surface_embeddings,
],
axis=-1,
)
embed_init = hk.initializers.VarianceScaling(embed_init_scale, mode="fan_out")
proj_mat_3 = hk.get_parameter(
"proj_mat_3",
[post_author_embedding.shape[-1], D],
dtype=jnp.float32,
init=lambda shape, dtype: embed_init(list(reversed(shape)), dtype).T,
)
history_embedding = jnp.dot(post_author_embedding.astype(proj_mat_3.dtype), proj_mat_3).astype(
post_author_embedding.dtype
)
history_embedding = history_embedding.reshape(B, S, D)
history_padding_mask = (history_post_hashes[:, :, 0] != 0).reshape(B, S)
return history_embedding, history_padding_mask

Then, the entire sequence (user embedding plus history embeddings) is passed through the Grok transformer. The output is averaged across all valid positions and normalized to produce a single user representation vector:

def build_user_representation(
self,
batch: RecsysBatch,
recsys_embeddings: RecsysEmbeddings,
) -> Tuple[jax.Array, jax.Array]:
"""Build user representation from user features and history.
Uses the Phoenix transformer to encode user + history embeddings
into a single user representation vector.
Args:
batch: RecsysBatch containing hashes, actions, product surfaces
recsys_embeddings: RecsysEmbeddings containing pre-looked-up embeddings
Returns:
user_representation: L2-normalized user embedding [B, D]
user_norm: Pre-normalization L2 norm [B, 1]
"""
config = self.config
hash_config = config.hash_config
history_product_surface_embeddings = self._single_hot_to_embeddings(
batch.history_product_surface, # type: ignore
config.product_surface_vocab_size,
config.emb_size,
"product_surface_embedding_table",
)
history_actions_embeddings = self._get_action_embeddings(batch.history_actions) # type: ignore
user_embeddings, user_padding_mask = block_user_reduce(
batch.user_hashes, # type: ignore
recsys_embeddings.user_embeddings, # type: ignore
hash_config.num_user_hashes,
config.emb_size,
1.0,
)
history_embeddings, history_padding_mask = block_history_reduce(
batch.history_post_hashes, # type: ignore
recsys_embeddings.history_post_embeddings, # type: ignore
recsys_embeddings.history_author_embeddings, # type: ignore
history_product_surface_embeddings,
history_actions_embeddings,
hash_config.num_item_hashes,
hash_config.num_author_hashes,
1.0,
)
embeddings = jnp.concatenate([user_embeddings, history_embeddings], axis=1)
padding_mask = jnp.concatenate([user_padding_mask, history_padding_mask], axis=1)
model_output = self.model(
embeddings.astype(self.fprop_dtype),
padding_mask,
candidate_start_offset=None,
)
user_outputs = model_output.embeddings
mask_float = padding_mask.astype(jnp.float32)[:, :, None] # [B, T, 1]
user_embeddings_masked = user_outputs * mask_float
user_embedding_sum = jnp.sum(user_embeddings_masked, axis=1) # [B, D]
mask_sum = jnp.sum(mask_float, axis=1) # [B, 1]
user_representation = user_embedding_sum / jnp.maximum(mask_sum, 1.0)
user_norm_sq = jnp.sum(user_representation**2, axis=-1, keepdims=True)
user_norm = jnp.sqrt(jnp.maximum(user_norm_sq, EPS))
user_representation = user_representation / user_norm
return user_representation, user_norm

So, to summarize: the User Tower is a full transformer that reads the user’s recent engagement history and outputs a single normalized vector representing something like “what this user is interested in right now.”

Retrieval

Now the system has both towers producing normalized vectors in the same space. The Candidate Tower has pre-computed embeddings for all posts in the corpus (this surely happens offline at certain moments). The User Tower computes the representation on the fly when the user opens the “For You” section.

Retrieval is then extraordinarily simple: it just computes the dot product between the user vector and all candidate vectors, and returns the top-K highest scores. Just what you would have imagined.

3. Deciding the order of the posts

At this point, the system has candidates from two different sources: in-network posts from people you follow, and out-of-network posts from Phoenix Retrieval. Now, how does the system rank them?

Essentially, this stage of the recommendation pipeline uses the very same transformer architecture that powers the User Tower to score the different candidates. One of the most important details here is that a mask is applied so that candidates cannot attend to each other. The authors refer to this as Candidate Isolation. This is the exact diagram they published:

     Keys (what we attend TO)
     ─────────────────────────────────────────────▶
     │ User │    History (S positions)    │   Candidates (C positions)    │
┌────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ U  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✗   ✗   ✗   ✗   ✗   ✗   ✗    │
├────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ H  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✗   ✗   ✗   ✗   ✗   ✗   ✗    │
├────┼──────┼─────────────────────────────┼───────────────────────────────┤
│ C  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✓   ✗   ✗   ✗   ✗   ✗   ✗    │
│ a  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✗   ✓   ✗   ✗   ✗   ✗   ✗    │
│ n  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✗   ✗   ✓   ✗   ✗   ✗   ✗    │
│ d  │  ✓   │  ✓   ✓   ✓   ✓   ✓   ✓   ✓  │  ✗   ✗   ✗   ✓   ✗   ✗   ✗    │
└────┴──────┴─────────────────────────────┴───────────────────────────────┘

In a nutshell, each candidate attends to itself but not to other candidates. This means that post A will get the exact same score whether it is evaluated alongside post B or post Z. As a result, the score is deterministic and cacheable. This is the code that creates this:

def make_recsys_attn_mask(seq_len, candidate_start_offset, dtype=jnp.float32):
# Start with causal mask
causal_mask = jnp.tril(jnp.ones((1, 1, seq_len, seq_len), dtype=dtype))
# Zero out candidate-to-candidate attention (bottom-right block)
attn_mask = causal_mask.at[:, :, candidate_start_offset:, candidate_start_offset:].set(0)
# Add back self-attention for candidates (diagonal only)
candidate_indices = jnp.arange(candidate_start_offset, seq_len)
attn_mask = attn_mask.at[:, :, candidate_indices, candidate_indices].set(1)
return attn_mask
Multi-Action Prediction

This part is really interesting. The ranking model doesn’t predict a single “relevance score.” Instead, it predicts probabilities for 15+ different actions you might take on each post: favorite, reply, repost, quote, click, profile_click, photo_expand, share, and follow_author. It also computes probabilities for negative actions such as not_interested, block_author, mute_author, and report.

This is fundamentally different from systems that optimize for a single metric like “click-through rate” or “dwell time.” The model is trained to understand the full spectrum of possible reactions, including negative ones.

These logits are then converted into probabilities via a sigmoid function, giving us the predicted probability of each action for each candidate.

Putting it all together: the final score

So, as you can imagine, the simplest way to merge all those 15+ probabilities together is through a simple weighted sum:

let combined_score = Self::apply(s.favorite_score, p::FAVORITE_WEIGHT)
+ Self::apply(s.reply_score, p::REPLY_WEIGHT)
+ Self::apply(s.retweet_score, p::RETWEET_WEIGHT)
+ Self::apply(s.photo_expand_score, p::PHOTO_EXPAND_WEIGHT)
+ Self::apply(s.click_score, p::CLICK_WEIGHT)
+ Self::apply(s.profile_click_score, p::PROFILE_CLICK_WEIGHT)
+ Self::apply(s.vqv_score, vqv_weight)
+ Self::apply(s.share_score, p::SHARE_WEIGHT)
+ Self::apply(s.share_via_dm_score, p::SHARE_VIA_DM_WEIGHT)
+ Self::apply(s.share_via_copy_link_score, p::SHARE_VIA_COPY_LINK_WEIGHT)
+ Self::apply(s.dwell_score, p::DWELL_WEIGHT)
+ Self::apply(s.quote_score, p::QUOTE_WEIGHT)
+ Self::apply(s.quoted_click_score, p::QUOTED_CLICK_WEIGHT)
+ Self::apply(s.dwell_time, p::CONT_DWELL_TIME_WEIGHT)
+ Self::apply(s.follow_author_score, p::FOLLOW_AUTHOR_WEIGHT)
+ Self::apply(s.not_interested_score, p::NOT_INTERESTED_WEIGHT)
+ Self::apply(s.block_author_score, p::BLOCK_AUTHOR_WEIGHT)
+ Self::apply(s.mute_author_score, p::MUTE_AUTHOR_WEIGHT)
+ Self::apply(s.report_score, p::REPORT_WEIGHT);

Here’s the interesting part: all the weights are hidden in configuration files. Without knowing them, we don’t really know how different actions are weighted. Technically, computing such a large set of probabilities allows them to experiment almost infinitely without changing anything in the architecture.

The structure also tells us something very relevant: negative signals have negative weights. If the model predicts that you are likely to block the author, that post’s score gets pushed down.

This is actually more thoughtful than pure engagement optimization. A post that makes you rage-click and then block the author might have high engagement, but the block prediction would penalize it, at least in theory.

Author Diversity

After weighted scoring, there’s one more step: author diversity. If the same author appears multiple times in your feed, their subsequent posts get attenuated (the way this is done is very elegant, by the way):

fn multiplier(&self, position: usize) -> f64 {
(1.0 - self.floor) * self.decay_factor.powf(position as f64) + self.floor
}

4. Filtering out some posts

Even though the system already has final scores for the posts, there is one last step before showing them: filtering out content you don’t want to see. These are just binary decisions (nothing very fancy) simply keeping or removing posts.

In the post-scoring stage (there is also a pre-scoring filtering step that removes some candidates even before ranking), the system applies several filters. These include removing posts containing muted words, posts from accounts you’ve blocked, very old posts, and similar cases. At this stage, there are essentially two additional filters: posts containing violence, gore, or policy violations (yes, even X applies these), and multiple posts from the same conversation thread.

After all of this, you end up with the final set of posts you will see.

5. Some final (non-technical) thoughts

There are a lot of interesting takeaways from studying this system in depth. First of all, it seems to be a very simple and fairly standard architecture when compared to other state-of-the-art recommendation engines that different platforms use. This is actually pretty interesting: either they are working on something fancier right now, or this semantic/sequence-based system works well enough for the moment.

Secondly, it is clear why many people criticize this system: it is designed in a way that makes creating echo chambers overwhelmingly easy. The system literally tries not to show you things that can bother you (which is clear from the fact that the probability of negative effects decreases the final weight of a post). In that sense, you will almost always end up seeing things that reinforce your beliefs.

But this leads me to my final thought, as someone who works every day on recommendation systems: a natural (and inevitable) question is whether this is a consequence created by design. In other words, is this a malevolent plan from Elon to create even louder echo chambers and keep hurting democracy? Or is it just an MVP system that happens to be very simple (from a data science point of view, it actually is) because they didn’t have much time to deeply redesign the previous Twitter algorithm?

Recommendation engines are known for one simple goal: trying to optimize something and, therefore, bring more revenue somehow. There might be caveats, but this is pretty much it. And here comes the painful and uncomfortable part: people are not willing to see what they don’t like. People don’t want their opinions to be challenged. People want to think they are right. In that sense, it is very natural that systems like this are simply trying to optimize whatever keeps you engaged. Because they want to make money. They want you to stay.

I won’t completely exonerate people like me, who are trying every day to improve these systems, from the unpredictable consequences of recommending what people want to see (because, in the end, claiming that we are “just doing our job” is just a modern and techy variation of Hannah Arendt’s banality of evil). But I also won’t say that users don’t have a considerable portion of responsibility for letting a system model their opinions. Let me put this in a more direct way:

you also have responsibility!

We can all have our own opinions. And, as you saw in this post, it is enough to manually read a few posts from someone you don’t like for the system to start showing you more of that. The system is not considering what you write, it only considers what you are doing. Not being in an echo chamber starts with our own individual decisions in the digital space.

Now let’s hope the system decides this post is relevant enough to show it to you.

2025

Ni el pormenor simbólico

de reemplazar un tres por un dos

ni esa metáfora baldía

que convoca un lapso que muere y otro que surge

ni el cumplimiento de un proceso astronómico

aturden y socavan

la altiplanicie de esta noche

y nos obligan a esperar

las doce irreparables campanadas.

La causa verdadera

es la sospecha general y borrosa

del enigma del Tiempo;

es el asombro ante el milagro

de que a despecho de infinitos azares,

de que a despecho de que somos

las gotas del río de Heráclito,

perdure algo en nosotros:

inmóvil,

algo que no encontró lo que buscaba.

Inteligencia

Entender es relacionar, encontrar la unidad bajo la diversidad. Un acto de inteligencia es darse cuenta de que la caída de una manzana y el movimiento de la Luna, que no cae, están regidos por la misma ley.


Como una especie de detective secular en una Gran Novela Policial, la
inteligencia persigue interminablemente a la verdad, buscándola hasta en los lugares menos sospechosos; está abierta a todas las posibilidades y por eso debe combatir a cada instante contra la rutina, el lugar común, el dogma y la superstición, que pretenden en cada caso haber aclarado el enigma, ignorando o queriendo ignorar que la verdad tiene infinitos cómplices e infinitos lugares diferentes.


Porque combate contra todos los dogmas y supersticiones, la inteligencia es capaz de comprender lo que hay de verdad en cada uno de ellos; un hombre inteligente no se caracteriza porque no comete errores sino que está dispuesto a rectificar los cometidos; los hombres que no cometen errores y que tienen todo definitivamente resuelto son los dogmáticos: se caracterizan por tener una Iglesia, una Ortodoxia, un Papa infalible, una Inquisición; no hay que creer que estas organizaciones sólo aparecen para defender a Dios: algunas aparecen para demostrar su inexistencia.


La creación de estas Iglesias es lo que hace tan difícil la búsqueda de la
verdad. Porque entonces no basta la inteligencia: se requiere la intrepidez. Se requiere mucho valor para defender a la vez la parte de verdad en Berkeley contra los marxistas y la parte de verdad en los marxistas contra Berkeley. Este valor intelectual es lo que los fanáticos de la secta llaman confusionismo.


Lo difícil de esta tarea está en que la inteligencia debe proceder en forma helada e imparcial en este interminable pleito siendo que a la vez aparece encarnada en forma humana y, por lo tanto, mezclada con la debilidad, la simpatía, la violencia, el fanatismo y la furia, que son nuestros atributos más frecuentes.

3 de julio

Dios habla con cada uno de nosotros mientras nos da vida,
después, al sacarnos de la noche, nos acompaña silenciosamente.

Estas son las palabras que débilmente oímos:

Tú, enviado más allá de tu memoria,
ve hasta los límites de tu anhelo.
Encárname.

Resplandece como llama
y proyecta grandes sombras en las que pueda adentrarme.

Deja que todo te acontezca: la belleza y el terror.
Y sigue adelante. Ningún sentimiento es definitivo.
No te permitas perderme.

El lugar que algunos llaman vida ya está cerca.
Lo conocerás por su llaneza.

Toma mi mano.

Votar no es un acto de fe

Prácticamente desde que era un niño me he interesado por la política. Y esto no solo no me enorgullece, sino que he de decirlo con una suerte de vergüenza: he gastado mucha más energía mental en esto que algunas otras personas, muchas de las cuales admiro y quiero profundamente. Pero, en fin, uno al final no decide sus intereses, por más que lo intente.

Los últimos tres años han sido, en definitiva, una montaña rusa en cuanto al ambiente político en Colombia. Tal vez ese sea, en realidad, el estado natural de la política en nuestro país. Pero esta vez estaba el ingrediente de tener a una persona profundamente diferente a todos los que habían gobernado antes, lo cual hacía sentir las cosas como si fueran nuevas, desconocidas, aun si no eran más que unos cuantos refritos de algo que ya se había vivido.

Sea como fuere, en las últimas semanas he visto una desbandada sorprendente de personas que se han venido desmarcando del gobierno por el cual votaron (dicen ellos) llenos de esperanza. Y más allá de reflexionar sobre el gobierno y el porqué hemos llegado a esta situación (cosa que hago mucho en Twitter/X, mucho más de lo que debería y me gustaría), no puedo evitar querer dejar plasmada una reflexión sobre el acto en sí mismo de ser ciudadano y tener la responsabilidad de votar.

En 2018, siendo mi primera elección presidencial, voté por Fajardo en primera vuelta y por Petro en segunda. Fui más allá: invité a votar por Petro y me alegré cuando vi que sacó 8 millones de votos. Era una persona graduada, salida de una universidad pública, y llevaba en mi maleta una cantidad de ideales heredados de mis convicciones anteriores, que estaban un poco menos corrompidos por la realidad. En últimas, era ante todo alguien que ha estado (y está, para ser justo) de acuerdo con una cantidad no despreciable de argumentos que hoy esgrimen quienes votaron por Petro. Colombia es un país que puede dar más, mucho más. Girar el barco hacia otra dirección puede ayudar en ese recorrido.

Nunca fui particularmente petrista, sin embargo. Ciertamente tampoco era opositor como lo soy ahora. Pero llegó 2020. Fue en ese año cuando empezó a sentirse el baldado de realidad frente a esa visión idealista que alguna vez me hizo votar por Petro: él parecía ser una mala persona. Recuerdo perfectamente la forma en que atizaba el ambiente para pescar en río revuelto durante la mayor crisis que ha tenido la humanidad desde la Segunda Guerra Mundial. Sembrar un ambiente de duda frente a la vacunación, y hacerlo de manera velada, solamente con el ánimo de tumbar un gobierno, era, desde cualquier punto de vista, una simple y llana hijueputada. Y algo así solo lo puede hacer un hijueputa.

Había entendido algo algo: si el país iba a cambiar, no iba a ser bajo la batuta de alguien como Petro. Luego vino el 2021 y fue evidente que iba a ganar. Empecé a sentir miedo de sus intenciones. Lo que ha pasado desde 2022, y particularmente en el último año, me dice que no estuve del todo equivocado en esa apreciación.

En estas palabras está automáticamente incluida la extensión de esto a cualquier persona similar, como el caso de Uribe, por ejemplo. Y es que este es, precisamente, el aprendizaje principal (tal vez bastante poco mágico, si se quiere) al que he podido llegar acerca del acto de votar: uno no vota por una esperanza, uno vota por una persona. Si esa persona no está a la altura de esa esperanza (por más que sea quien lleve sus banderas), no merece ser votada.

Y para identificar si una persona no está a la altura de esas expectativas, solo basta observar. Ser un ciudadano crítico implica tomarse el derecho al voto como lo que es: una responsabilidad enorme. Y ese sentido crítico necesita menos de leer propuestas y más de leer personas. Es mucho menos probable que alguien que no es, esencialmente, una mala persona destruya un país, incluso si tiene ideas equivocadas. Lo contrario, en cambio, es el mayor peligro para una sociedad. Un país puede darse el lujo de escoger personas con ideas equivocadas de vez en cuando y vivir para contarlo. Pero de escoger malas personas no se vuelve.

Francisco

No es este un tema del que yo suela hablar, pero hace unos 10 años más o menos, tenía una convicción profundamente religiosa. A tal punto llegaba esto, que la vida religiosa aparecía en mis últimos años de colegio como una opción real en mi camino de vida.

Con el matiz que traen los años, entendí una realidad fundamental: la Iglesia (y, si quisiéramos llevarlo un poco más al extremo, las religiones) se ha construido sobre una entremezcla de cuestiones históricas y espirituales, pero esa combinación crea algo que no es del todo sacro ni del todo mundano.

Un ejemplo son las enseñanzas sobre ciertas cuestiones morales, como la homosexualidad. No existe ningún dogma de la Iglesia Católica que justifique su reprobación incuestionable. Cualquier versículo de los evangelios que pudiera sugerirlo (Mateo 19, 4-6; Marcos 7, 20-23) puede contraargumentarse con aquellos que hablan de la infinita misericordia de Jesús (Juan 8, 1-11).

Fue San Pablo, tomando algunas citas del Antiguo Testamento y sumándoles enseñanzas sobre reglas de pureza, quien comenzó a referirse por primera vez a este tipo de cuestiones. Y sobre esas ideas, la Iglesia fue construyendo concepciones que hasta hoy perduran.

La realidad es que no son pocas las enseñanzas que sugieren el carácter contestatario de Jesús y su enojo contra aquellos que anteponían las reglas al espíritu humano y su salvación (Mateo 23, 4; Lucas 11, 46; Mateo 12, 11-12).

Recuerdo perfectamente cuando eligieron a Francisco. Ya en ese momento estaba en el final de mis cuestionamientos de fe (o más bien, vocacionales), que inevitablemente me estaban acercando a caminos muy diferentes. Aun con todo eso, siempre creí que Francisco era un representante vivo de todas las ideas que prefiero creer que tenía Jesús.

Y aunque el don de la fe se me fue apagando con los años, creo que cualquier pastor que llegue al mundo a enseñar el amor por el otro, sin cuestionamientos, sin prejuicios ni odios, es un ser humano que merece ser honrado.

14 de abril

No te separes nunca, le aconseja Quela. Se lo dice ella, que se las arregla tan bien sola, que anda sin miedo a altas horas de la noche en su carro traqueteante, que es capaz de irse a una cabaña solitaria en la playa a pasar un mes, que puede leer con calma un manual de instrucciones y luego armar cualquier aparato que venga por partes, algo que Emilia jamás podría hacer. A veces la vida en pareja es agotadora, replica esta, la negociación permanente, la lucha por el territorio, la obligación de complacer. Pero la soledad. No te imaginas lo que puede pesar a veces la soledad.

2024

Ni el pormenor simbólico

de reemplazar un tres por un dos

ni esa metáfora baldía

que convoca un lapso que muere y otro que surge

ni el cumplimiento de un proceso astronómico

aturden y socavan

la altiplanicie de esta noche

y nos obligan a esperar

las doce irreparables campanadas.

La causa verdadera

es la sospecha general y borrosa

del enigma del Tiempo;

es el asombro ante el milagro

de que a despecho de infinitos azares,

de que a despecho de que somos

las gotas del río de Heráclito,

perdure algo en nosotros:

inmóvil,

algo que no encontró lo que buscaba.

Cuando la IA se vuelve física: Redes de Hopfield

Si ustedes son medianamente nerds o entusiastas (lo cual, de alguna manera, es prácticamente lo mismo), ya sabrán que la Academia Sueca se ha puesto nuevamente en el ojo del huracán: le otorgó el premio Nobel de Física del 2024 a dos de los padres de la inteligencia artificial, John Hopfield y Geoffrey Hinton.

Para ser franco, el premio es, cuanto menos, excéntrico. No porque Hopfield y Hinton no hayan hecho contribuciones enormes, sino porque claramente parece una decisión, digamos, política. De alguna forma, la Academia Sueca se adhiere a la opinión generalizada (y correcta, me parece) de que la inteligencia artificial es un punto de inflexión en la ciencia y la tecnología, y lo hace, por supuesto, reconociéndolo con el que es probablemente el premio más relevante del mundo científico. Pero esto es harina de otro costal.

Pero la noticia es una excelente excusa para hacer algo que en algún momento quise hacer: escribir un poco sobre las redes de Hopfield y su interpretación como sistemas dinámicos. Y es que las redes de Hopfield, a diferencia del perceptrón, no son tan conocidas a pesar de ser una idea bellísima y bastante inteligente. Voy a partir la entrada en secciones, algunas más matemáticas que otras, pero intentando mantener la coherencia entre ellas.

1. Recordando a partir de información incompleta

Empecemos hablando de uno de los problemas fundamentales que intentan resolver las redes de Hopfield: el problema de memoria asociativa. Supongamos que queremos diseñar un sistema que sea capaz de almacenar patrones y estados de memoria de tal forma que, si el sistema recibe como entrada un conjunto de datos con información parcial, sea capaz de retornar el estado de memoria más cercano a esos datos. Básicamente, queremos un sistema que sea capaz de recordar la historia completa a partir de información incompleta o parcialmente errónea.

Esta habilidad, estudiada ampliamente en la psicología cognitiva (les suena familiar, ¿no?), aplicada al ámbito de la ciencia de la computación permite aproximar la solución de problemas como la recuperación de información, compresión de datos o problemas en la transferencia en sistemas de comunicación.

Así, las redes de Hopfield buscan identificar patrones almacenados previamente y, a partir de una entrada incompleta o ruidosa, recuperar la información más cercana al patrón original. Esta es una de las primeras diferencias entre las redes de Hopfield y los perceptrones: aunque ambos son redes neuronales, el perceptrón típicamente se utiliza para resolver problemas de clasificación en lugar de la recuperación de patrones almacenados explícitamente. El perceptrón no “guarda” patrones explícitos, sino que intenta aprender una función que generaliza una serie de datos para realizar predicciones.

2. Codificando patrones

En esta sección vamos a definir una red de Hopfield siguiendo el paper original de 1982. Consideremos el sistema sistema: definamos N unidades de procesamiento o neuronas indexadas con i=1, 2, \cdots N. Cada neurona va a tener en un momento t un estado binario S_i \in \lbrace 0, 1 \rbrace. El vector fila S(t) = \big(S_1(t), S_2(t) \cdots S_N(t) \big) es el estado de la red y contiene todos los estados de cada neurona. Si la neurona i está conectada con j, entonces la intensidad de esa conexión va a estar dada por un número w_{ij}. Vamos a asumir que cada neurona tiene una conexión nula consigo misma, es decir, w_{ii} = 0 para todo i. Además, cada neurona tiene asociada un valor \theta_i, que va a servir en el proceso de actualización.

La dinámica de esta red es bastante sencilla: básicamente, la actualización de la neurona i depende de la suma ponderada de los estados de las neuronas que se conectan con ella de la siguiente forma:

S_i(t+1) = \begin{cases} 1 & \text{si } \sum_{j \neq i} w_{ij}S_j(t) \geq \theta_i \\ 0 & \text{si} \sum_{j \neq i} w_{ij}S_j(t) < \theta_i \end{cases} = \displaystyle \frac{1}{2} \Bigg(\text{sign} \bigg(\displaystyle \sum_{j \neq i}w_{ij}S_j(t) - \theta_i \bigg) + 1 \Bigg).

Donde \text{sign} es simplemente la función signo. La actualización del estado de cada neurona se da de forma asincrónica. Es decir, se selecciona una neurona en cada momento y se repite este procedimiento para todas ellas. Para simplificar el modelo, vamos a escoger los números \theta_1, \theta_2, \cdots, \theta_N como cero. Si además definimos unos nuevos estados T_i = 2S_i-1 que toman valores en \lbrace -1, 1 \rbrace entonces se tiene que para el momento t+1,

T_i(t+1) = \text{sign} \bigg(\displaystyle \sum_{j \neq i}w_{ij}T_j(t) \bigg).

Ahora bien, supongamos ahora que queremos almacenar el estado \zeta = (\zeta_1, \zeta_2, \cdots, \zeta_n). ¿Cómo deberíamos definir los pesos para asegurar que el procedimiento anterior recupere cada uno de los componentes de este estado en algún punto y no cambie más? Si hacemos que T(t) = \zeta para algún t (es decir, los estados alcanzan alguna evolución del estado guardado), entonces si definimos w_{ij} = \zeta_i \zeta_j obtenemos

T_i(t+1) = \text{sign} \bigg(\displaystyle \sum_{j \neq i}\zeta_i \zeta_j T_j(t) \bigg) = \text{sign} \bigg(\displaystyle \sum_{j \neq i}\zeta_i \zeta_j^2\bigg) = \text{sign} \bigg(\displaystyle \sum_{j \neq i}\zeta_i \bigg) = \zeta_i,

pues \zeta_j ^2 = 1. Esto implica que llegado a este punto ya no habría ningún cambio y el sistema habría encontrado el patrón guardado. Matricialmente, la matriz de pesos se puede definir simplemente como W = \zeta^T \zeta por lo que la dinámica escrita en forma matricial es simplemente T(t+1)= \text{sign}(T(t)\zeta^T \zeta ) = \text{sign}(T(t) \zeta^T \zeta ).

3. Pero, ¿por qué funciona?

So far, so good. Tal como se definió en la sección anterior, la red de Hopfield es un caso de un sistema dinámico discreto. Además, lo que probamos en la última parte es que este sistema dinámico tiene como punto fijo a \zeta, que es nuestro estado a almacenar. Todo esto suena muy bien, pero falta probar algo fundamental: que efectivamente el sistema converge a \zeta si la trayectoria inicial está suficientemente cerca. En el argot de los sistemas dinámicos, lo que eso signifca es que queremos probar que \zeta es un punto fijo estable.

Para probar eso, vamos a utilizar el bien reputado teorema de Lyapunov. Básicamente, para demostrar que un punto fijo es estable, se debe encontrar una función V que satisfaga que es acotada por debajo y que además sea una función decreciente, es decir, E(T(t+1)) \leq E(T(t)). En general, el teorema de Lyapunov no da absolutamente ninguna pista acerca de una función adecuada a escoger1, por lo que es más un arte que cualquier otra cosa.

En este caso, se puede probar que con el procedimiento descrito, la siguiente función satisface todas las condiciones:

E(T(t)) =-\displaystyle \frac{1}{2} T(t) W T(t)^T = - \displaystyle \frac{1}{2} T(t) \zeta^T \zeta T(t)^T =  \displaystyle \frac{1}{2} \displaystyle \sum_{i,j} T_i(t)\zeta_i \zeta_j T_j(t) .

Veamos la monotonía: queremos calcular la diferencia entre dos tiempos sucesivos:

E(T(t+1)) - E(T(t)) =  -\displaystyle \frac{1}{2} \displaystyle \sum_{i,j} T_i(t+1)\zeta_i \zeta_j T_j(t+1) + \displaystyle \frac{1}{2} \displaystyle \sum_{i,j} T_i(t)\zeta_i \zeta_j T_j(t).

Ahora bien, como la actualización se está haciendo de forma asincrónica, quiere decir que en el paso t \rightarrow t+1 únicamente una neurona tiene un posible cambio, llamémosla k. Por lo tanto, T_i(t+1)=T_i(t) para todo i \neq k y así:

E(T(t+1)) - E(T(t)) =- (T_k(t+1) - T_k(t)) \displaystyle \sum_{i \neq k}   \zeta_k \zeta_i  T_i(t) \leq 0.

Con esto quedaría entonces probado que el estado \zeta es de hecho un punto de equilibrio estable del sistema, con lo cual, con el proceso de actualización de las neuronas alcanzamos dicho estado en algún momento. Detalles más, detalles menos, pero esta es la idea.

4. Haciéndolo más interesante

De manera bastante conveniente, presenté la derivación en el caso en el que quisiéramos almacenar únicamente un solo estado \zeta. Lo interesante, sin embargo, es cuando queremos almacenar varios estados \zeta^1, \zeta^2, \cdots, \zeta^m. Toda la teoría anterior es aplicable de manera idéntica en este caso, con la única diferencia de que la matriz W va a estar definida de esta forma:

W = \displaystyle \sum_{i=1}^m (\zeta^i)^T \zeta^i.

Se suele usar una normalización con \dfrac{1}{m} para evitar que los valores de W se vuelvan muy grandes, pero esto no es del todo necesario.

Hay muchos resultados interesantes cuando ya se intentan almacenar varios estados dentro de la red. Por ejemplo, se puede probar que la capacidad de almacenamiento (es decir, la cantidad de patrones que puede almacenar esta red y que permitan recuperarse de manera adecuada) tiene una cota superior de 0.138N.

5. Visualizando la memoria en acción

Las matemáticas son muy lindas, sí, pero esta entrada se quedaría a medias sin una visualización. Implementar una red de Hopfield siguiendo este esquema es extremadamente sencillo, y cualquier LLM les puede dar la estructura estándar, que no debería tener más de unas cuantas líneas.

En mi ejemplo, utilicé el dataset de MNIST 784 de Sklearn para extraer unos cuantos patrones y ver una red de Hopfield en acción. En ese caso, las imágenes del dataset son de tamaño 28 \times 28, por lo que el número de neuronas debe ser N = 784. La red fue entrenada con tres patrones diferentes:

Ahora, el objetivo es poner a prueba la capacidad de recuperación de la red. Para eso, tomé el patrón 3 y le puse ruido. Así se ve el input:

Teóricamente, la red ha aprendido algunos patrones que le van a permitir identificar el patrón de la izquierda. ¿Funciona? Bueno, vean ustedes mismos la red encontrando el patrón:

Lo que ven en la parte superior de la imagen es la función de energía de la red conforme avanza cada paso. Como ven, la energía decrece hasta el punto en que encuentra el patrón de manera perfecta. Esto ocurre en este ejemplo de juguete porque la red simplemente almacenó 3 patrones. Si hubiesen más, el desempeño esperado para esta red pequeña de apenas 784 neuronas es bastante malo.

6. ¿Y entonces?

Sí, ya sé lo que están pensando. No suena algo bastante sofisticado, especialmente en la época de los LLMs. Y de hecho tienen razón: no lo es. Esta derivación aparece en 1982, hace ya más de 40 años. Pero el punto, como en todas estas cosas, es que estas ideas fundacionales fueron guiando poco a poco a los avances que tenemos hoy.

Las ideas sobre las redes de Hopfield particularmente hoy han aparecido en investigaciones que buscan mejorar los mecanismos de los transformers, la piedra angular de la IA moderna.

En lo personal, las redes de Hopfield siempre me parecieron fascinantes por su sencillez y elegancia de sus ideas. No serán potencialmente tan potentes como sus otras redes hermanas, pero al menos, parece, en el 2024 pueden motivar un premio Nobel. Más o menos.

  1. Esto lo aprendí a la brava. Básicamente mi tesis de maestría se basó en encontrar una función de Lyapunov para un proceso estocástico. ↩︎