A language model needs TWO conceptually different matrices shaped (vocab_size, d_model): an INPUT embedding table ([04-seq-modeling/02-embeddings/01-token-embedding-lookup], looking a token id UP to get a vector) and an OUTPUT projection (turning the model's final hidden state into a score, a "logit," for every word in the vocabulary, so a softmax over those logits gives next-token probabilities). Press & Wolf (2016) observed something genuinely useful: these two matrices, despite serving different DIRECTIONS of the same lookup (id-to-vector vs. vector-to-id-scores), can literally be the SAME matrix, "tied" together, rather than two entirely separate, independently-learned parameter sets, without hurting model quality, and while meaningfully REDUCING the total parameter count (a vocab_size * d_model-sized matrix, often a substantial fraction of a smaller model's total parameters, gets counted only once instead of twice).
The alternative, UNTIED embeddings, uses two entirely separate matrices: the model learns its own best input representation independently from its own best output-scoring function, at the cost of the extra parameters. Whether tying helps or hurts is itself an empirical, model-size-dependent question, smaller models often benefit measurably from tying (proportionally, the savings matter more, and sharing information between the "read" and "write" directions of the vocabulary can act as a useful regularizer), while very large models are more often trained untied, having plenty of capacity to spare and, in some cases, benefiting from letting the two roles specialize independently.
Implement output_projection_tied(hidden_states, embedding_table), projecting hidden_states to vocabulary logits by reusing an EXISTING embedding table (transposed), and output_projection_untied(hidden_states, output_weight), the same projection using a SEPARATE, independently-learned matrix.
hidden_states @ matrix.T, matrix being embedding_table in the tied case and output_weight in the untied case; both matrices are shaped (vocab_size, d_model).output_weight is entirely independent: changes to some other, unrelated embedding table must have no effect on it whatsoever.Both are literally the same one-line computation: return hidden_states @ matrix.T. The distinction between "tied" and "untied" isn't in the MATH at all, it's entirely in WHICH matrix gets passed in, and whether that same matrix object is ALSO used elsewhere (as the input embedding table) or not.
Because NumPy arrays are mutable, passing the exact same embedding_table array into BOTH [04-seq-modeling/02-embeddings/01-token-embedding-lookup]'s lookup AND this question's output_projection_tied means any later modification to that array (e.g. a gradient update during training) is automatically visible from both call sites, no extra syncing code required. That's the entire mechanism weight tying relies on.
Click "Run Tests" to test your implementation