<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Clementina Tom]]></title><description><![CDATA[ML engineer writing about recommendation systems, knowledge tracing, and production AI. Open-source builder. Author of PLRS.]]></description><link>https://clementina-tom.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/65db982e9db34ba4bb2c594f/9fee96c4-b696-4084-a8b5-350254de769e.png</url><title>Clementina Tom</title><link>https://clementina-tom.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 01 Sep 2026 22:48:42 GMT</lastBuildDate><atom:link href="https://clementina-tom.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why Recommendation Systems Fail in Education (And What I Built Instead)]]></title><description><![CDATA[While most learning platforms recommend what students are likely to succeed at, I wanted to build one that recommends what they need to learn—with respect to how human memory actually works.
The Probl]]></description><link>https://clementina-tom.hashnode.dev/why-recommendation-systems-fail-in-education-and-what-i-built-instead</link><guid isPermaLink="true">https://clementina-tom.hashnode.dev/why-recommendation-systems-fail-in-education-and-what-i-built-instead</guid><category><![CDATA[deep knowledge tracing]]></category><category><![CDATA[edtech]]></category><category><![CDATA[Recommendation System]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[AI]]></category><category><![CDATA[AI in education]]></category><category><![CDATA[ml-in-education]]></category><category><![CDATA[SAKT]]></category><category><![CDATA[SAKTwithDecay]]></category><dc:creator><![CDATA[Clementina Tom]]></dc:creator><pubDate>Thu, 04 Jun 2026 22:00:35 GMT</pubDate><content:encoded><![CDATA[<p><em>While most learning platforms recommend what students are likely to succeed at, I wanted to build one that recommends what they need to learn—with respect to how human memory actually works.</em></p>
<h2>The Problem Nobody Talks About</h2>
<p>Recommendation systems are one of the most mature technologies in machine learning. Big companies use some variant of collaborative filtering or matrix factorization that works remarkably well.</p>
<p>But education is a different domain entirely. Applying the same approaches without modification produces results that are not just suboptimal—they are <strong>educationally harmful</strong>.</p>
<p>The core problem: in entertainment, a wrong recommendation costs a user two hours. In learning, a wrong recommendation can cost a student weeks of confusion and lost confidence—because they were sent to study something they were not yet ready to understand. [1]</p>
<p>To quantify exactly how bad this problem is, I ran a baseline experiment before building anything: I trained standard Collaborative Filtering (CF) and Matrix Factorization (MF) models on the Open University Learning Analytics Dataset (OULAD) [2] and evaluated their recommendations against a real curriculum graph—a directed acyclic graph encoding prerequisite relationships in Nigerian secondary school Mathematics and CS Fundamentals.</p>
<p><strong>Over 81% of CF recommendations and 83% of MF recommendations violated at least one prerequisite relationship.</strong> More than four in five suggestions were, in a meaningful educational sense, wrong. These models had no idea how knowledge is actually structured.</p>
<p>That baseline became the motivation for PLRS: the Personalized Learning Recommendation System.</p>
<h2>What Makes Education Different</h2>
<p>Standard recommendation systems are built on one assumption: <em>patterns in the past predict useful items in the future.</em> This works because in entertainment, there are no hard dependencies between items. You could watch a sequel without seeing the original.</p>
<p>Knowledge does not work this way. Curriculum structure is not a preference graph—it is a dependency graph. You cannot learn calculus before algebra. You cannot understand recursion without understanding functions. These are hard constraints, not soft signals.</p>
<p>There are three specific ways standard approaches fail in educational settings:</p>
<ul>
<li><p><strong>No prerequisite awareness.</strong> CF and MF learn from interaction patterns but have no concept of curriculum structure. They cannot distinguish a sequence that respects learning order from one that violates it.</p>
</li>
<li><p><strong>Optimising for engagement, not learning.</strong> These models recommend content a student is likely to succeed at—not what they most need. Recommending easy material stalls genuine knowledge growth.</p>
</li>
<li><p><strong>No model of forgetting.</strong> A student who mastered a topic three months ago is treated identically to one who studied it yesterday. Human memory decays [3], and ignoring this consistently overestimates current mastery.</p>
</li>
</ul>
<p>Solving all three requires a system that actually models how students learn, forget, and progress through structured knowledge.</p>
<h2>The Architecture: Four Components, One Pipeline</h2>
<img src="https://cdn.hashnode.com/uploads/covers/65db982e9db34ba4bb2c594f/e0222059-5aad-41cf-b0e2-e385528f3b39.png" alt="System Architecture Diagram -- full pipeline from student interaction logs to ranked recommendations" style="display:block;margin:0 auto" />

<p><em>System Architecture Diagram -- full pipeline from student interaction logs to ranked recommendations. Generated by ChatGPT. Image by author</em></p>
<p>PLRS is built around four tightly integrated components, each addressing a specific failure mode of standard approaches.</p>
<h3>Component 1: Knowledge Tracing with SAKTWithDecay</h3>
<p>The foundation is a knowledge tracing model that estimates a student's current mastery across all concepts based on their interaction history. The baseline is <strong>SAKT (Self-Attentive Knowledge Tracing)</strong>, introduced by Pandey and Karypis (2019) [4]. SAKT uses multi-head self-attention over interaction sequences to capture cross-concept dependencies.</p>
<p>But SAKT treats all past interactions equally regardless of when they occurred. This ignores a fundamental property of human memory: <strong>forgetting</strong>.</p>
<p>PLRS v2 introduces <strong>SAKTWithDecay</strong> -- a modified architecture where the decay is applied directly inside the attention mechanism rather than as a pre-processing step. Instead of weighting embeddings before attention, a custom DecayAttention module penalises attention scores by a learned, per-head log function of positional distance—approximating the Ebbinghaus forgetting curve [3] within the attention computation itself. The decay rates are learned parameters, constrained positive via softplus.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65db982e9db34ba4bb2c594f/21fb76d1-682c-4c11-9970-a5403646f6e3.png" alt="Ebbinghaus Forgetting Curve -- retention decays as a function of elapsed time. PLRS approximates this via learned attention score penalties." style="display:block;margin:0 auto" />

<p><em>Ebbinghaus Forgetting Curve—retention decays as a function of elapsed time. PLRS approximates this via learned attention score penalties. Image by author</em></p>
<pre><code class="language-python">class DecayAttention(nn.Module):
    """
    Multi-head attention with Ebbinghaus forgetting curve decay.
    Decay penalty: rate_h * log(1 + |i - j|) -- learned per head.
    """
    def __init__(self, embed_dim, num_heads, dropout=0.2, decay_init=1.0):
        super().__init__()
        self.embed_dim = embed_dim
        self.num_heads = num_heads
        self.head_dim = embed_dim // num_heads
        self.q_proj = nn.Linear(embed_dim, embed_dim)
        self.k_proj = nn.Linear(embed_dim, embed_dim)
        self.v_proj = nn.Linear(embed_dim, embed_dim)
        self.out_proj = nn.Linear(embed_dim, embed_dim)
        self.dropout = nn.Dropout(dropout)
        # Learned decay rate per head -- constrained positive via softplus
        self.decay_logit = nn.Parameter(
            torch.full((num_heads,), math.log(math.exp(decay_init) - 1))
        )

    def forward(self, x, causal_mask, key_padding_mask):
        B, L, D = x.shape
        H, Hd = self.num_heads, self.head_dim
        Q = self.q_proj(x).view(B, L, H, Hd).transpose(1, 2)
        K = self.k_proj(x).view(B, L, H, Hd).transpose(1, 2)
        V = self.v_proj(x).view(B, L, H, Hd).transpose(1, 2)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(Hd)
        # Ebbinghaus decay: penalise distant past interactions
        positions = torch.arange(L, device=x.device)
        dist = (positions.unsqueeze(0) - positions.unsqueeze(1)).abs().float()
        decay_rate = F.softplus(self.decay_logit)  # (H,)
        decay_penalty = decay_rate.view(H, 1, 1) * torch.log1p(dist).unsqueeze(0)
        scores = scores - decay_penalty.unsqueeze(0)
        scores = scores.masked_fill(causal_mask.unsqueeze(0).unsqueeze(0), -1e9)
        if key_padding_mask is not None:
            scores = scores.masked_fill(
                key_padding_mask.unsqueeze(1).unsqueeze(2), -1e9
            )
        attn = self.dropout(F.softmax(scores, dim=-1))
        out = torch.matmul(attn, V).transpose(1, 2).contiguous().view(B, L, D)
        return self.out_proj(out)
</code></pre>
<p>The key line is <code>scores = scores - decay_penalty</code>. Before softmax, each attention score is penalised in proportion to the log-distance between positions, scaled by a learned head-specific rate. Interactions that are positionally distant—a proxy for temporally distant—are attended to less. Each attention head learns its own decay rate, allowing the model to capture both fast-decaying and slow-decaying knowledge patterns simultaneously.</p>
<h3>Component 2: The Prerequisite Knowledge Graph</h3>
<p>The second component eliminates the 81% violation rate: a <strong>directed acyclic graph (DAG)</strong> encoding prerequisite relationships between knowledge concepts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65db982e9db34ba4bb2c594f/4f29bba4-12a6-470f-9890-0adfa741d7e5.png" alt="Prerequisite DAG -- Secondary School Mathematics curriculum (38 nodes, 45 edges). Arrows indicate required prerequisite direction." style="display:block;margin:0 auto" />

<p><em>Prerequisite DAG—Secondary School Mathematics curriculum (38 nodes, 45 edges). Arrows indicate required prerequisite direction. Generated by ChatGPT, Image by author</em></p>
<p>An edge from concept A to concept B means a student must achieve sufficient mastery of A before being recommended B. This is enforced as a <strong>hard constraint</strong>—a candidate concept is never scored by the ranking function unless its full prerequisite chain is satisfied.</p>
<p>I built two domain-specific graphs from the Nigerian NERDC secondary school curriculum: Secondary School Mathematics (38 nodes, 45 edges, JSS3-SS2) and CS Fundamentals (31 nodes, 39 edges).</p>
<pre><code class="language-python">def get_next_learnable(
    dag: nx.DiGraph,
    mastery_vector: dict,
    threshold: float = 0.7
) -&gt; list:
    """
    Return concepts whose full prerequisite chain is satisfied.
    Hard filter: concept is learnable only if ALL prerequisites
    meet the mastery threshold.
    """
    mastered = {c for c, m in mastery_vector.items() if m &gt;= threshold}
    learnable = []
    for concept in dag.nodes():
        if concept in mastered:
            continue
        prereqs = nx.ancestors(dag, concept)
        if all(mastery_vector.get(p, 0.0) &gt;= threshold for p in prereqs):
            learnable.append(concept)
    return learnable
</code></pre>
<p>The 0.0% prerequisite violation rate is not a model output—it is a structural guarantee. No concept that fails the prerequisite check ever reaches the scoring stage.</p>
<h3>Component 3: Multi-Objective Ranking with SuperMemo-2</h3>
<p>Given a set of learnable candidates, the ranking function scores each concept across three objectives:</p>
<ul>
<li><p><strong>Predicted performance (alpha = 0.40):</strong> Mastery probability from SAKTWithDecay.</p>
</li>
<li><p><strong>Knowledge gain (beta = 0.40):</strong> Concepts just beyond current mastery score highest—operationalising Vygotsky's Zone of Proximal Development [5].</p>
</li>
<li><p><strong>Spaced repetition urgency (gamma = 0.20):</strong> SuperMemo-2 [6] computes the optimal next review interval per concept. Overdue concepts receive an urgency boost even when current mastery appears adequate.</p>
</li>
</ul>
<p>The spaced repetition component closes the loop between learning new material and retaining what was already studied —a property purely forward-looking recommenders lack entirely.</p>
<h3>Component 4: The FastAPI Backend</h3>
<p>All four ranking signals are integrated into a production FastAPI backend with four endpoints: <code>/recommend</code>, <code>/what-if</code>, <code>/curriculum</code>, <code>/health</code>. The package ships with API key authentication across four tiers, sliding window rate limiting, and a CI pipeline with 109 tests passing across Python 3.10-3.12 via GitHub Actions.</p>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Prereq. Violation Rate</th>
<th>Val AUC</th>
</tr>
</thead>
<tbody><tr>
<td>Collaborative Filtering</td>
<td>81.3%</td>
<td>0.71</td>
</tr>
<tr>
<td>Matrix Factorization</td>
<td>83.7%</td>
<td>0.69</td>
</tr>
<tr>
<td>PLRS v1 (SAKT baseline)</td>
<td>0.0%</td>
<td>0.7692</td>
</tr>
<tr>
<td><strong>PLRS v2 (SAKTWithDecay)</strong></td>
<td><strong>0.0%</strong></td>
<td><strong>0.8613</strong></td>
</tr>
</tbody></table>
<p>The headline result is the 0.0% prerequisite violation rate across all PLRS versions. Standard approaches violate prerequisite structure more than 80% of the time—not as an edge case, but as default behaviour.</p>
<p>SAKTWithDecay achieves a validation AUC of 0.8613 versus 0.7692 for the vanilla SAKT baseline—a meaningful improvement reflecting the additional signal provided by learned forgetting curve decay. Full training was performed on a corrected OULAD preprocessing pipeline: time-aware, four-table join, leakage-free.</p>
<p>Beyond the numbers: the system produces pedagogically coherent recommendation sequences. A student beginning Secondary School Mathematics is routed through number theory -&gt; basic algebra -&gt; linear equations -&gt; quadratic equations, in the correct dependency order, with spaced repetition surfacing earlier concepts for revision as the student advances.</p>
<h2>The Open Research Problem: Learning the Graph</h2>
<p>The most significant limitation is one every constraint-aware recommender shares: <strong>the prerequisite graph has to be built by hand.</strong> Building curriculum graphs for every subject, grade level, and educational system is not a viable path to a general-purpose system.</p>
<p>The direction I am pursuing next is <strong>automated prerequisite discovery from student interaction data</strong> using Bayesian network structure learning [7]. The core idea: if students who master concept A consistently perform better on concept B shortly afterward, there is statistical evidence for a prerequisite relationship—without any curriculum expert specifying it explicitly.</p>
<p>DAG discovery methods like the PC algorithm [8] and GES have been explored in smaller educational settings. Applying them to large-scale interaction datasets like OULAD, combining structure learning with knowledge tracing, and validating discovered graphs against expert-annotated curricula.</p>
<h2>Closing Thoughts</h2>
<p>The 81% prerequisite violation rate is not a failure of implementation —it is a failure of problem framing. Collaborative filtering was designed for a world where items are independent. Knowledge is not independent.</p>
<p>Building PLRS taught me that the interesting ML problems in education are not about achieving higher AUC on interaction prediction benchmarks. They are about what constraints the model should respect, what cognitive properties it should encode, and what it means for a recommendation to be right in a domain where wrong has real consequences.</p>
<p>The 0.0% prerequisite violation rate is the difference between a system that recommends and a system that teaches. The forgetting curve decay is the difference between a system that models performance and one that models memory. SuperMemo-2 is the difference between a scheduler and a tutor.</p>
<h2>References</h2>
<p><strong>[1]</strong> Corbett, A. T., &amp; Anderson, J. R. (1994). <a href="https://link.springer.com/article/10.1007/BF01099821">Knowledge tracing: Modeling the acquisition of procedural knowledge. User Modeling and User-Adapted Interaction</a>, 4(4), 253-278.</p>
<p><strong>[2]</strong> Kuzilek, J., Hlosta, M., &amp; Zdrahal, Z. (2017). <a href="https://www.nature.com/articles/sdata2017171">Open University Learning Analytics dataset. Scientific Data</a>, 4, 170171.</p>
<p><strong>[3]</strong> Ebbinghaus, H. (1885). Uber das Gedachtnis. Duncker &amp; Humblot. <a href="https://archive.org/download/memorycontributi00ebbiuoft/memorycontributi00ebbiuoft.pdf">English translation: Memory: A Contribution to Experimental Psychology, 1913.</a></p>
<p><strong>[4]</strong> Pandey, S., &amp; Karypis, G. (2019). <a href="https://arxiv.org/abs/1907.06837">A self-attentive model for knowledge tracing. Proceedings of the 12th International Conference on Educational Data Mining (EDM 2019).</a></p>
<p><strong>[5]</strong> Vygotsky, L. S. (1978). <a href="https://www.hup.harvard.edu/books/9780674576292">Mind in society: The development of higher psychological processes</a>. Harvard University Press.</p>
<p><strong>[6]</strong> Wozniak, P. A., Gorzelanczyk, E. J., &amp; Murakowski, J. A. (1995). <a href="https://pubmed.ncbi.nlm.nih.gov/8713361/">Two components of long-term memory. Acta Neurobiologiae Experimentalis</a>, 55(4), 301-305.</p>
<p><strong>[7]</strong> Heckerman, D., Geiger, D., &amp; Chickering, D. M. (1995). <a href="https://link.springer.com/article/10.1023/A:1022623210503">Learning Bayesian networks: The combination of knowledge and statistical data</a>. Machine Learning, 20(3), 197-243.</p>
<p><strong>[8]</strong> Spirtes, P., Glymour, C., &amp; Scheines, R. (2000). <a href="https://mitpress.mit.edu/9780262527927/causation-prediction-and-search">Causation, Prediction, and Search (2nd ed.)</a>. MIT Press.</p>
<h2>Resources</h2>
<p><strong>GitHub:</strong> <a href="https://github.com/clementina-tom/PLRS">clementina-tom/PLRS</a></p>
<p><strong>HuggingFace:</strong> <a href="https://huggingface.co/Clementio/PLRS">Clementio/PLRS</a></p>
<p><strong>Live Demo:</strong> <a href="https://huggingface.co/spaces/clementio/PLRS-Demo">PLRS on HuggingFace Spaces</a></p>
<p><strong>Part 1:</strong> <a href="https://clementina-tom.hashnode.dev/the-logic-engine-building-a-constraint-aware-ai-recommendation-system-for-personalized-learning">The Logic Engine: Building a Constraint-Aware AI Recommendation System for Personalized Learning</a></p>
<hr />
<p><em>Clementina Tom is an ML engineer specialising in recommendation systems, knowledge tracing, and production MLOps. She builds open-source tools at the intersection of machine learning and education.</em></p>
<p>GitHub: <a href="https://github.com/clementina-tom">github.com/clementina-tom</a></p>
]]></content:encoded></item><item><title><![CDATA[The Logic Engine: Building a Constraint-Aware AI Recommendation System for Personalized Learning]]></title><description><![CDATA[How I combined Deep Knowledge Tracing, curriculum graph theory, and multi-objective ranking to build a recommendation system that actually understands how people learn --- and achieves a 0.0% prerequi]]></description><link>https://clementina-tom.hashnode.dev/the-logic-engine-building-a-constraint-aware-ai-recommendation-system-for-personalized-learning</link><guid isPermaLink="true">https://clementina-tom.hashnode.dev/the-logic-engine-building-a-constraint-aware-ai-recommendation-system-for-personalized-learning</guid><category><![CDATA[AI in education]]></category><category><![CDATA[Personalized Learning]]></category><category><![CDATA[Recommenders in education]]></category><category><![CDATA[deep knowledge tracing]]></category><category><![CDATA[transformers]]></category><category><![CDATA[educational AI]]></category><category><![CDATA[pedagodical learning]]></category><dc:creator><![CDATA[Clementina Tom]]></dc:creator><pubDate>Sat, 02 May 2026 23:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/65db982e9db34ba4bb2c594f/fcd840d9-3c16-4bb9-b135-50255274d1a8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>How I combined Deep Knowledge Tracing, curriculum graph theory, and multi-objective ranking to build a recommendation system that actually understands how people learn --- and achieves a 0.0% prerequisite violation rate.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65db982e9db34ba4bb2c594f/cd457c03-6630-444d-89ca-ca623eee4edc.png" alt="" style="display:block;margin:0 auto" />

<hr />
<h2>The Problem With "Smart" Recommenders in Education</h2>
<p>Recommendation systems are everywhere. On the internet, social media, shopping platforms. Netflix knows what you want to watch next. Spotify curates your weekend playlist. Amazon predicts your next purchase before you've think of it.</p>
<p>But for education, this is different.</p>
<p>In entertainment, a wrong recommendation may waste a few minutes. But for learning, a wrong recommendation could waste weeks --- or worse, permanently frustrate a student who was never ready for what they were given.</p>
<p>The core failure mode of standard recommenders in education is this: <strong>they optimize for engagement or predicted performance, but they don't know that you can't learn calculus before you understand algebra.</strong></p>
<p>Collaborative Filtering (CF) and Matrix Factorization --- the workhorses of modern recommendation --- learn patterns from what students have done before. But they have no concept of <em>prerequisite structure</em>. They don't know that polynomial equations come before differentiation. They don't know that loops must precede recursion.</p>
<p>I wanted to know how bad this problem actually was. So I tested it.</p>
<p>When I ran standard CF and Matrix Factorization baselines against a real curriculum graph, <strong>over 81% of their recommendations violated at least one prerequisite relationship.</strong> More than 4 in 5 suggestions were, in a meaningful educational sense, wrong --- not because the model predicted poor performance, but because the model had no idea how knowledge is structured.</p>
<p>To close that gap, I built the <strong>Logic Engine</strong>.</p>
<hr />
<h2>What Is the Logic Engine?</h2>
<p>The Logic Engine is a domain-agnostic, constraint-aware personalized learning recommendation framework. It combines three components:</p>
<ol>
<li><p>A <strong>Deep Knowledge Tracing model (SAKT)</strong> --- to estimate a student's current mastery state across all knowledge concepts</p>
</li>
<li><p>A <strong>prerequisite knowledge graph (DAG)</strong> --- encoding the structural dependencies between concepts in a given curriculum</p>
</li>
<li><p>A <strong>multi-objective ranking function</strong> --- that scores candidate recommendations by balancing predicted performance, knowledge gain, and prerequisite compliance</p>
</li>
</ol>
<p>The system is designed to be domain-agnostic: the same architecture works for any curriculum, as long as you can define the knowledge concepts and their dependencies. I built and tested it on two domains drawn from the Nigerian NERDC secondary school curriculum: <strong>Secondary School Mathematics</strong> (JSS3--SS2) and <strong>CS Fundamentals</strong>.</p>
<p><strong>GitHub:</strong> <a href="https://github.com/clementina-tom/PLRS">clementina-tom/PLRS</a></p>
<p><strong>HuggingFace:</strong> <a href="https://huggingface.co/Clementio/PLRS">Clementio/PLRS</a></p>
<p><strong>Live Demo:</strong> <a href="https://xbo78uswdvbsmsnka87e4j.streamlit.app/">Streamlit App</a></p>
<hr />
<h2>System Architecture: End-to-End</h2>
<img src="https://your-image-url-here.com/architecture-diagram.png" alt="System architecture diagram showing the five-layer pipeline from raw data to ranked recommendations" style="display:block;margin:0 auto" />

<img alt="" style="display:block;margin:0 auto" />

<p><em>Figure 1: The Logic Engine's five-layer pipeline. Each layer is independently swappable. Diagram by the clementina-tom.</em></p>
<p>The pipeline has five distinct layers. Each layer has one job, and each can be swapped or upgraded independently:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Input</th>
<th>Output</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Data Pipeline</strong></td>
<td>Raw OULAD logs</td>
<td>Encoded interaction sequences</td>
</tr>
<tr>
<td><strong>SAKT Model</strong></td>
<td>Interaction sequences</td>
<td>Student mastery vector</td>
</tr>
<tr>
<td><strong>Knowledge Graph</strong></td>
<td>NERDC curriculum spec</td>
<td>Prerequisite DAG</td>
</tr>
<tr>
<td><strong>Constraint Layer</strong></td>
<td>Mastery vector + DAG</td>
<td>Filtered candidate set</td>
</tr>
<tr>
<td><strong>Ranking Function</strong></td>
<td>Filtered candidates + scores</td>
<td>Ordered recommendations</td>
</tr>
</tbody></table>
<p>Let me walk through each layer --- with the real code that drives it.</p>
<hr />
<h2>Component 1: Data Pipeline</h2>
<p><strong>Dataset:</strong> Open University Learning Analytics Dataset (OULAD)</p>
<p>OULAD contains anonymized interaction logs from ~32,000 students across multiple OU modules. It records student activity types, dates, click counts, and assessment results --- a rich source of sequential learning interaction data.</p>
<p>The critical preprocessing step is <strong>skill encoding</strong>: mapping raw OULAD activity labels to curriculum knowledge concepts, so the model operates on educationally meaningful units rather than arbitrary content IDs.</p>
<pre><code class="language-python">import pandas as pd
import numpy as np

def build_interaction_sequences(student_logs: pd.DataFrame,
                                skill_encoder: pd.DataFrame,
                                max_seq_len: int = 100) -&gt; dict:
    """
    Transform raw OULAD student logs into SAKT-ready interaction sequences.
    
    Args:
        student_logs: DataFrame with columns [student_id, activity_type, date, score]
        skill_encoder: Mapping from activity_type -&gt; concept_id
        max_seq_len: Maximum sequence length (pad/truncate to this)
    
    Returns:
        Dictionary of {student_id: {"concepts": [...], "responses": [...],
                                    "timestamps": [...]}}
    """
    # Merge activity logs with skill encoder
    merged = student_logs.merge(skill_encoder, on="activity_type", how="inner")
    merged = merged.sort_values(["student_id", "date"])
    
    sequences = {}
    
    for student_id, group in merged.groupby("student_id"):
        concepts = group["concept_id"].tolist()
        # Binarize: score &gt;= 50 -&gt; correct (1), else incorrect (0)
        responses = (group["score"] &gt;= 50).astype(int).tolist()
        timestamps = group["date"].tolist()
        
        # Truncate to max_seq_len from the right (most recent interactions)
        if len(concepts) &gt; max_seq_len:
            concepts = concepts[-max_seq_len:]
            responses = responses[-max_seq_len:]
            timestamps = timestamps[-max_seq_len:]
        
        sequences[student_id] = {
            "concepts": concepts,
            "responses": responses,
            "timestamps": timestamps,
            "seq_len": len(concepts)
        }
    
    return sequences


def initialize_cold_start_mastery(n_concepts: int) -&gt; np.ndarray:
    """
    For students with no interaction history, initialize mastery to 0.
    Cold-start students will be recommended foundational concepts first.
    """
    return np.zeros(n_concepts, dtype=np.float32)
</code></pre>
<p>The skill encoding step is domain-specific --- this is where you map your content to your knowledge graph. The rest of the pipeline is domain-agnostic.</p>
<hr />
<h2>Component 2: SAKT --- The Knowledge Tracing Model</h2>
<p><strong>Self-Attentive Knowledge Tracing (SAKT)</strong> is a transformer-based model designed to track a student's knowledge state over time as they interact with learning content.</p>
<p>Unlike traditional Bayesian Knowledge Tracing (BKT), which models one skill at a time, SAKT uses a self-attention mechanism to capture cross-concept dependencies --- recognizing, for example, that correctly answering a question on quadratic equations is evidence of algebraic competency more broadly.</p>
<pre><code class="language-python">import torch
import torch.nn as nn
import torch.nn.functional as F

class SAKTModel(nn.Module):
    """
    Self-Attentive Knowledge Tracing model.
    Based on: "A Self-Attentive model for Knowledge Tracing" (Pandey &amp; Karypis, 2019)
    
    The key idea: use self-attention over a student's interaction history
    to predict the probability of correctly answering each concept next.
    This probability distribution becomes the student's mastery vector.
    """
    
    def __init__(self, n_concepts: int, embed_dim: int = 64,
                 n_heads: int = 4, dropout: float = 0.2, max_seq: int = 100):
        super().__init__()
        self.n_concepts = n_concepts
        self.embed_dim = embed_dim
        
        # Embed (concept, response) pairs into a shared space
        # We use 2*n_concepts because each concept has a correct/incorrect embedding
        self.interaction_embed = nn.Embedding(2 * n_concepts + 1, embed_dim,
                                               padding_idx=0)
        self.concept_embed = nn.Embedding(n_concepts + 1, embed_dim,
                                           padding_idx=0)
        self.pos_embed = nn.Embedding(max_seq, embed_dim)
        
        # Multi-head self-attention: the core of the model
        self.attention = nn.MultiheadAttention(embed_dim, n_heads,
                                                dropout=dropout, batch_first=True)
        self.layer_norm = nn.LayerNorm(embed_dim)
        self.dropout = nn.Dropout(dropout)
        
        # Output head: predict P(correct | concept, history)
        self.output_head = nn.Sequential(
            nn.Linear(embed_dim, embed_dim // 2),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(embed_dim // 2, 1),
            nn.Sigmoid()
        )
    
    def forward(self, concepts: torch.Tensor,
                responses: torch.Tensor) -&gt; torch.Tensor:
        """
        Args:
            concepts: (batch, seq_len) --- concept IDs in interaction order
            responses: (batch, seq_len) --- 0/1 correctness for each interaction
        
        Returns:
            mastery_vector: (batch, n_concepts) --- P(correct) for each concept
        """
        batch_size, seq_len = concepts.shape
        device = concepts.device
        
        # Encode past interactions as (concept + response) embeddings
        interaction_ids = concepts + responses * self.n_concepts  # shift for response
        interaction_ids = interaction_ids.clamp(0, 2 * self.n_concepts)
        
        x = self.interaction_embed(interaction_ids)
        
        # Add positional encoding
        positions = torch.arange(seq_len, device=device).unsqueeze(0).expand(batch_size, -1)
        x = x + self.pos_embed(positions)
        x = self.dropout(x)
        
        # Causal mask: student can only attend to past interactions
        causal_mask = torch.triu(torch.ones(seq_len, seq_len, device=device),
                                  diagonal=1).bool()
        
        # Self-attention over interaction history
        attn_out, _ = self.attention(x, x, x, attn_mask=causal_mask)
        x = self.layer_norm(x + attn_out)
        
        # Predict mastery for ALL concepts using the final hidden state
        final_state = x[:, -1, :]  # (batch, embed_dim) --- most recent state
        
        all_concepts = torch.arange(1, self.n_concepts + 1, device=device)
        concept_embeds = self.concept_embed(all_concepts)  # (n_concepts, embed_dim)
        
        # Score each concept against the student's current state
        final_expanded = final_state.unsqueeze(1).expand(-1, self.n_concepts, -1)
        combined = final_expanded * concept_embeds.unsqueeze(0)
        
        mastery_vector = self.output_head(combined).squeeze(-1)  # (batch, n_concepts)
        return mastery_vector


def get_student_mastery(model: SAKTModel,
                        student_sequence: dict,
                        device: str = "cpu") -&gt; np.ndarray:
    """
    Infer a student's current mastery vector from their interaction history.
    Returns an array of shape (n_concepts,) with values in [0, 1].
    """
    model.eval()
    with torch.no_grad():
        concepts = torch.tensor([student_sequence["concepts"]],
                                 dtype=torch.long).to(device)
        responses = torch.tensor([student_sequence["responses"]],
                                  dtype=torch.long).to(device)
        mastery = model(concepts, responses)
        return mastery.squeeze(0).cpu().numpy()
</code></pre>
<p><strong>Training results:</strong></p>
<ul>
<li><p>Validation AUC: <strong>0.7692</strong> (26 epochs with early stopping on val AUC)</p>
</li>
<li><p>Model hosted on HuggingFace: <a href="https://huggingface.co/Clementio/PLRS">Clementio/PLRS</a></p>
</li>
</ul>
<p>The mastery vector output --- a probability distribution over all knowledge concepts --- is the snapshot of what the student knows right now. This feeds directly into the ranking function.</p>
<hr />
<h2>Component 3: The Prerequisite Knowledge Graph</h2>
<p>This is the most educationally interesting component, and the one that most clearly differentiates the Logic Engine from standard approaches.</p>
<p>A <strong>Directed Acyclic Graph (DAG)</strong> encodes prerequisite relationships between knowledge concepts. An edge from concept A -&gt; concept B means: a student must have sufficient mastery of A before being recommended B.</p>
<pre><code class="language-python">import networkx as nx
from typing import List, Set

def build_curriculum_dag(edges: List[tuple]) -&gt; nx.DiGraph:
    """
    Construct a prerequisite knowledge graph from a list of (prerequisite, concept) edges.
    
    Example edge: ("basic_algebra", "linear_equations") means
    "a student must understand basic algebra before linear equations."
    """
    G = nx.DiGraph()
    G.add_edges_from(edges)
    
    # Validate: a curriculum graph must be acyclic
    if not nx.is_directed_acyclic_graph(G):
        cycles = list(nx.find_cycle(G))
        raise ValueError(f"Curriculum graph contains cycles: {cycles}. "
                         f"Check your prerequisite definitions.")
    return G


# Secondary School Mathematics knowledge map (NERDC JSS3--SS2)
MATHS_EDGES = [
    ("number_theory", "fractions"),
    ("fractions", "ratio_proportion"),
    ("ratio_proportion", "percentages"),
    ("percentages", "financial_maths"),
    ("number_theory", "basic_algebra"),
    ("basic_algebra", "linear_equations"),
    ("linear_equations", "simultaneous_equations"),
    ("basic_algebra", "indices_logarithms"),
    ("linear_equations", "quadratic_equations"),
    ("quadratic_equations", "polynomials"),
    ("polynomials", "differentiation"),
    ("differentiation", "integration"),
    ("basic_algebra", "sequences_series"),
    ("fractions", "statistics_basics"),
    ("statistics_basics", "probability"),
    # ... 45 edges total
]

# CS Fundamentals knowledge map (NERDC)
CS_EDGES = [
    ("variables", "data_types"),
    ("data_types", "operators"),
    ("operators", "control_flow"),
    ("control_flow", "loops"),
    ("loops", "functions"),
    ("functions", "recursion"),
    ("data_types", "arrays"),
    ("arrays", "sorting_algorithms"),
    ("functions", "oop_basics"),
    ("oop_basics", "inheritance"),
    # ... 39 edges total
]

maths_dag = build_curriculum_dag(MATHS_EDGES)
cs_dag = build_curriculum_dag(CS_EDGES)


def get_prerequisites(dag: nx.DiGraph, concept: str) -&gt; Set[str]:
    """
    Return all concepts that must be mastered before `concept`.
    Uses ancestor traversal: returns the full transitive prerequisite closure.
    """
    return nx.ancestors(dag, concept)


def get_next_learnable(dag: nx.DiGraph,
                       mastered: Set[str],
                       threshold: float = 0.7,
                       mastery_vector: dict = None) -&gt; List[str]:
    """
    Return concepts whose full prerequisite chain is satisfied.
    A concept is learnable if all its prerequisites are in the mastered set
    (i.e., mastery score &gt;= threshold).
    """
    learnable = []
    for concept in dag.nodes():
        if concept in mastered:
            continue  # Already mastered
        prereqs = get_prerequisites(dag, concept)
        if all(mastery_vector.get(p, 0.0) &gt;= threshold for p in prereqs):
            learnable.append(concept)
    return learnable
</code></pre>
<p>The DAG construction is the most domain-specific part of the system. In production, this could be sourced from a curriculum authority, generated semi-automatically from textbook structure, or learned from expert annotation.</p>
<hr />
<h2>Component 4: Multi-Objective Ranking Function</h2>
<p>Given a student's mastery vector and a set of learnable candidates, the ranking function scores each concept on three dimensions and surfaces the best next steps:</p>
<pre><code class="language-python">import numpy as np
from dataclasses import dataclass
from typing import Dict, List

@dataclass
class RankingWeights:
    """
    Tunable weights for the multi-objective scoring function.
    Different pedagogical contexts call for different priorities.
    
    Examples:
    - Remediation mode: upweight knowledge_gain (fill gaps first)
    - Exam prep mode: upweight predicted_performance (reinforce strengths)
    - Exploration mode: increase novelty_bonus (broaden exposure)
    """
    predicted_performance: float = 0.40   # alpha
    knowledge_gain: float = 0.40          # beta
    prerequisite_satisfaction: float = 0.20  # gamma


def score_candidate(concept: str,
                    mastery_vector: Dict[str, float],
                    dag: nx.DiGraph,
                    weights: RankingWeights,
                    mastery_threshold: float = 0.7) -&gt; float:
    """
    Compute a multi-objective score for a candidate concept.
    
    Score = alpha * predicted_performance
          + beta * knowledge_gain
          + gamma * prerequisite_satisfaction
    
    Args:
        concept: Concept ID to score
        mastery_vector: {concept_id: mastery_probability} from SAKT
        dag: Prerequisite knowledge graph
        weights: Tunable objective weights
        mastery_threshold: Minimum mastery to consider a prerequisite satisfied
    
    Returns:
        Scalar score in [0, 1] --- higher is a better recommendation
    """
    # alpha --- Predicted performance: P(student succeeds) from SAKT
    predicted_performance = mastery_vector.get(concept, 0.0)
    
    # beta --- Knowledge gain: how much is this concept at the frontier of mastery?
    # Concepts just beyond current mastery score highest (zone of proximal development)
    current_mastery = mastery_vector.get(concept, 0.0)
    knowledge_gain = 1.0 - current_mastery  # High gain if low mastery
    
    # gamma --- Prerequisite satisfaction: are all prerequisites sufficiently mastered?
    prereqs = get_prerequisites(dag, concept)
    if not prereqs:
        prereq_score = 1.0  # No prerequisites -&gt; fully eligible
    else:
        prereq_scores = [mastery_vector.get(p, 0.0) for p in prereqs]
        prereq_score = np.mean([s / mastery_threshold for s in prereq_scores])
        prereq_score = min(prereq_score, 1.0)  # Cap at 1.0
    
    score = (weights.predicted_performance * predicted_performance
             + weights.knowledge_gain * knowledge_gain
             + weights.prerequisite_satisfaction * prereq_score)
    
    return float(score)


def rank_recommendations(mastery_vector: Dict[str, float],
                         dag: nx.DiGraph,
                         top_k: int = 5,
                         weights: RankingWeights = None,
                         mastery_threshold: float = 0.7) -&gt; List[dict]:
    """
    Full recommendation pipeline: filter -&gt; score -&gt; rank.
    
    1. Filter: remove already-mastered concepts and prerequisite violations
    2. Score: apply multi-objective scoring to each candidate
    3. Rank: return top-k by score
    
    Returns:
        List of dicts: [{"concept": ..., "score": ..., "reason": ...}, ...]
    """
    if weights is None:
        weights = RankingWeights()
    
    mastered = {c for c, m in mastery_vector.items() if m &gt;= mastery_threshold}
    
    # Step 1: Constraint filtering --- only learnable concepts pass through
    candidates = get_next_learnable(dag, mastered,
                                    threshold=mastery_threshold,
                                    mastery_vector=mastery_vector)
    
    if not candidates:
        return []  # Student has mastered everything in the graph
    
    # Step 2: Score each candidate
    scored = []
    for concept in candidates:
        score = score_candidate(concept, mastery_vector, dag, weights,
                                mastery_threshold)
        scored.append({"concept": concept, "score": round(score, 4)})
    
    # Step 3: Rank and return top-k
    ranked = sorted(scored, key=lambda x: x["score"], reverse=True)
    return ranked[:top_k]
</code></pre>
<p>The constraint layer is where the 0.0% violation rate comes from: <strong>no concept passes through unless its full prerequisite chain is satisfied.</strong> This is a hard filter, not a soft penalty --- the ranking function never even sees a violating concept.</p>
<hr />
<h2>Results: The Number That Matters</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Prerequisite Violation Rate</th>
<th>Val AUC</th>
</tr>
</thead>
<tbody><tr>
<td>Collaborative Filtering</td>
<td>81.3%</td>
<td>0.71</td>
</tr>
<tr>
<td>Matrix Factorization</td>
<td>83.7%</td>
<td>0.69</td>
</tr>
<tr>
<td><strong>Logic Engine (PLRS)</strong></td>
<td><strong>0.0%</strong></td>
<td><strong>0.7692</strong></td>
</tr>
</tbody></table>
<p>This is the headline result. Standard approaches recommend concepts in educationally invalid sequences more than 80% of the time. The Logic Engine eliminates prerequisite violations entirely --- while maintaining competitive knowledge tracing accuracy.</p>
<p>Beyond the numbers, the system produces pedagogically sensible recommendation sequences --- always moving from foundational to advanced concepts, respecting the actual structure of the curriculum.</p>
<hr />
<h2>Live Demo</h2>
<p>The Logic Engine is deployed as a Streamlit application with two modes:</p>
<p><strong>Assessment Mode</strong> --- Input real student interaction data. The SAKT model computes a mastery vector, the constraint layer filters candidates, and the ranking function returns a prioritized recommendation list.</p>
<p><strong>Simulate Mode</strong> --- Explore recommendations for a hypothetical student profile. Select a knowledge domain and see how the system responds as you adjust the student's mastery state.</p>
<p><strong>Try the live demo here:</strong> <a href="https://xbo78uswdvbsmsnka87e4j.streamlit.app/">Streamlit App</a></p>
<hr />
<h2>What I'd Build Next</h2>
<p>Here's my honest engineering backlog:</p>
<p><strong>1. Soft prerequisite constraints:</strong> The current system treats prerequisites as hard binary gates. In reality, learning is fuzzy --- a student with 60% mastery of algebra can still benefit from early exposure to quadratics. A soft constraint system would apply graduated penalties rather than hard cutoffs, enabling more nuanced recommendations.</p>
<p><strong>2. SHAP-style attention visualization:</strong> The SAKT model uses attention weights internally to relate past interactions to current predictions. Surfacing these as explainability outputs --- "this recommendation is based heavily on your performance in Algebra last week" --- would make the system far more interpretable to both students and educators.</p>
<p><strong>3. What-If prerequisite simulator:</strong> An interactive tool that lets a student (or teacher) ask: "What would I need to master before being ready for Calculus?" The prerequisite DAG already encodes this --- it just needs a traversal interface.</p>
<p><strong>4. AUC improvement:</strong> Val AUC of 0.7692 is solid but not the ceiling. Better sequence length handling, hyperparameter tuning, and curriculum-aligned data augmentation are all on the roadmap.</p>
<hr />
<h2>Closing Thoughts</h2>
<p>The Logic Engine is an argument that <strong>educational AI needs domain knowledge, not just data</strong>.</p>
<blockquote>
<p>A model that optimizes purely for predicted performance will recommend whatever the student is most likely to score well on --- not what they most need to learn next. A model that knows the structure of a curriculum can do something more powerful: chart a coherent path through knowledge.</p>
</blockquote>
<p>The 0.0% prerequisite violation metric of our logic engine is the difference between a system that recommends, and a system that teaches.</p>
<p>The full project is open-source. Code, model weights, and datasets are available below:</p>
<p><strong>GitHub:</strong> <a href="https://github.com/clementina-tom/PLRS">clementina-tom/PLRS</a></p>
<p><strong>HuggingFace:</strong> <a href="https://huggingface.co/Clementio/PLRS">Clementio/PLRS</a></p>
<p><strong>Live Demo:</strong> <a href="https://xbo78uswdvbsmsnka87e4j.streamlit.app/">Streamlit App</a></p>
<hr />
<p><em>Clementina | ML Engineer --- RAG pipelines, MLOps, and predictive modeling |</em> <a href="https://github.com/clementina-tom"><em>GitHub</em></a></p>
]]></content:encoded></item></channel></rss>