<?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" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by huseyinceniik on Medium]]></title>
        <description><![CDATA[Stories by huseyinceniik on Medium]]></description>
        <link>https://medium.com/@huseyinceniik?source=rss-a27ac9830920------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*LpEH3o17APu4KsYX</url>
            <title>Stories by huseyinceniik on Medium</title>
            <link>https://medium.com/@huseyinceniik?source=rss-a27ac9830920------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 07:53:18 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@huseyinceniik/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Why Your Jira Comments Look Like Garbage (and the ADF Fix for It)]]></title>
            <link>https://medium.com/@huseyinceniik/why-your-jira-comments-look-like-garbage-and-the-adf-fix-for-it-df852cdc971c?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/df852cdc971c</guid>
            <category><![CDATA[jira]]></category>
            <category><![CDATA[json]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[atlassian]]></category>
            <category><![CDATA[developer-tools]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Thu, 17 Sep 2026 14:01:46 GMT</pubDate>
            <atom:updated>2026-09-17T14:01:46.849Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lot9XsH6jHPufmSLFKSBZg.png" /><figcaption>Mastering the Jira ADF Pipeline</figcaption></figure><blockquote>Jira Cloud’s API renders Markdown as literal text. Here’s why — and a dependency-free Python script that converts Markdown into real Atlassian Document Format (ADF) JSON.</blockquote><p>If you’ve ever posted a comment to a Jira Cloud ticket through an API call, a bot, or an AI agent, you’ve probably seen this:</p><pre>**Root cause:** the retry logic in the `sync` worker<br>| Step | Status |<br>|---|---|<br>| 1 | done |<br>| 2 | failed |</pre><p>…render as exactly that. Literal asterisks. Literal backticks. Literal pipes and dashes. No bold, no monospace, no table.</p><p>I ran into this while building an automation script that posts status updates and structured summaries to Jira tickets as part of an agent workflow. Every comment came out looking like Markdown wearing a costume — the syntax was there, but Jira just printed it as plain characters. This is the story of why that happens, and the small script that fixes it, verified live against a real ticket.</p><h3>Key takeaways</h3><ul><li><strong>Jira’s comment API doesn’t parse Markdown or wiki-markup at all.</strong> It wraps whatever string you send as one literal text node — that’s true whether you write **bold**, ||Header||, or anything else.</li><li><strong>The real fix is ADF</strong> (Atlassian Document Format), the JSON tree Jira’s own editor produces. Build that JSON and POST it to the REST API <strong>v3</strong> comment endpoint instead of v2.</li><li>A <strong>~300-line, dependency-free Python script</strong> can convert a Markdown file into valid ADF — headings, bold/italic/code, links, tables, ordered/bullet lists, task lists, fenced code blocks, and Jira’s colored panels.</li><li><strong>Task-list checkbox state is not scoped to your document.</strong> It’s tracked by a site-wide realtime service keyed on localId — use a real UUID per item, not a sequential counter, or checkboxes can silently inherit an unrelated task&#39;s checked state.</li><li>You can <strong>preview ADF JSON without posting to a real ticket</strong>, using Atlassian’s own Document Viewer tool.</li></ul><h3>The problem: Markdown renders as literal text in Jira</h3><p>I tested every reasonable variant before concluding it wasn’t a syntax mistake on my end:</p><ul><li>**bold** → shows as literal asterisks, not bold.</li><li>`inline code` → literal backticks, no monospace.</li><li># Heading → literal hash characters, no heading.</li><li>A Markdown pipe table (| a | b | + |---|---|) → literal pipes and dashes, no grid.</li><li>Jira’s <em>own</em> wiki-markup table syntax (||Header||) → <strong>also</strong> literal.</li><li>Only genuine Unicode characters (emoji) rendered correctly, because they aren’t markup at all — just characters.</li></ul><p>That last point was the tell. Whatever was posting these comments wasn’t failing to parse one particular markup dialect — it wasn’t parsing <em>any</em> markup. It was treating the entire string as inert text.</p><h3>Why this happens: Jira REST API v2 vs. v3</h3><p>Jira Cloud’s REST API has two comment endpoints that are easy to conflate:</p><ul><li><strong>v2</strong> — expects a plain string, optionally in the legacy wiki-markup dialect.</li><li><strong>v3</strong> — expects <strong>ADF (Atlassian Document Format)</strong>: a JSON document tree. This is the exact same format Jira’s own ProseMirror-based comment editor produces when a human types in the browser.</li></ul><p>Whatever tool or integration sits between “a string I want to post” and Jira usually wraps that string as a single literal ADF text node and sends it to the v3 endpoint. It never parses Markdown or wiki-markup first — it just boxes the raw string. So <em>any</em> syntax you throw at it renders literally, regardless of which flavor you used, because nothing downstream is looking for syntax at all.</p><p>The only real fix: build actual ADF JSON yourself and post it to the v3 endpoint directly.</p><h3>The fix: a Markdown-to-ADF converter in Python</h3><p>I wrote a single Python file, pure standard library, no dependencies, that reads a Markdown file and converts it to an ADF doc tree. It can either print the resulting JSON for inspection (--dry-run) or POST it directly as a real comment:</p><pre>POST /rest/api/3/issue/{key}/comment<br>Authorization: Basic base64(email:api_token)<br>Body: {&quot;body&quot;: &lt;adf-json&gt;}</pre><p>Auth follows the standard Jira Cloud API-token pattern — an account email plus a token generated at id.atlassian.com, and a base site URL.</p><h3>How it works: from Markdown text to an ADF tree</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0Ufb0NNViJbDDJ6IIiZOIQ.png" /><figcaption>Markdown → ADF conversion flow</figcaption></figure><p>At a high level, the converter runs in two passes:</p><ol><li><strong>Block splitting</strong> — walk the Markdown text line by line and group runs of lines into typed blocks: heading, paragraph, table, bullet/ordered list, task list, panel, fenced code, rule.</li><li><strong>Block-to-ADF conversion</strong> — turn each typed block into the matching ADF node, recursively parsing inline formatting (bold, code, italics, links) inside any text content.</li></ol><p>The block splitter is one function that looks just far enough ahead to tell block types apart — a table needs to peek at the next line for a |---|---| separator, a panel needs to scan forward to its closing :::, a fenced code block needs to consume everything up to the next fence:</p><pre>HEADING_RE = re.compile(r&quot;^(#{1,6})\s+(.*)$&quot;)<br>RULE_RE = re.compile(r&quot;^(-{3,}|\*{3,}|_{3,})\s*$&quot;)<br>TASK_RE = re.compile(r&quot;^[-*+]\s+\[([ xX])\]\s+(.*)$&quot;)<br>BULLET_RE = re.compile(r&quot;^[-*+]\s+(.*)$&quot;)<br>ORDERED_RE = re.compile(r&quot;^\d+\.\s+(.*)$&quot;)<br>TABLE_SEP_RE = re.compile(r&quot;^\s*\|?[\s:\-|]+\|?\s*$&quot;)<br>PANEL_START_RE = re.compile(r&quot;^:::panel\s+(info|note|warning|success|error)\s*$&quot;)<br>PANEL_END_RE = re.compile(r&quot;^:::\s*$&quot;)<br>BLOCK_START_RE = re.compile(<br>    r&quot;^(#{1,6}\s+|```|:::|[-*+]\s+|\d+\.\s+|(-{3,}|\*{3,}|_{3,})\s*$)&quot;<br>)</pre><pre>def split_into_blocks(md_text):<br>    lines = md_text.split(&quot;\n&quot;)<br>    n = len(lines)<br>    blocks = []<br>    i = 0<br>    while i &lt; n:<br>        line = lines[i]<br>        if line.strip() == &quot;&quot;:<br>            i += 1<br>            continue<br>        if line.startswith(&quot;```&quot;):<br>            lang = line[3:].strip()<br>            i += 1<br>            code_lines = []<br>            while i &lt; n and not lines[i].startswith(&quot;```&quot;):<br>                code_lines.append(lines[i])<br>                i += 1<br>            i += 1<br>            blocks.append((&quot;code&quot;, lang, code_lines))<br>            continue<br>        if HEADING_RE.match(line):<br>            blocks.append((&quot;heading&quot;, line))<br>            i += 1<br>            continue<br>        if RULE_RE.match(line.strip()):<br>            blocks.append((&quot;rule&quot;,))<br>            i += 1<br>            continue<br>        if &quot;|&quot; in line and i + 1 &lt; n and TABLE_SEP_RE.match(lines[i + 1]) and &quot;-&quot; in lines[i + 1]:<br>            table_lines = [line, lines[i + 1]]<br>            i += 2<br>            while i &lt; n and &quot;|&quot; in lines[i] and lines[i].strip() != &quot;&quot;:<br>                table_lines.append(lines[i])<br>                i += 1<br>            blocks.append((&quot;table&quot;, table_lines))<br>            continue<br>        panel_m = PANEL_START_RE.match(line.strip())<br>        if panel_m:<br>            panel_type = panel_m.group(1)<br>            i += 1<br>            inner_lines = []<br>            while i &lt; n and not PANEL_END_RE.match(lines[i].strip()):<br>                inner_lines.append(lines[i])<br>                i += 1<br>            i += 1  # skip the closing &#39;:::&#39;<br>            blocks.append((&quot;panel&quot;, panel_type, inner_lines))<br>            continue<br>        if TASK_RE.match(line):<br>            list_lines = []<br>            while i &lt; n and TASK_RE.match(lines[i]):<br>                list_lines.append(lines[i])<br>                i += 1<br>            blocks.append((&quot;task_list&quot;, list_lines))<br>            continue<br>        if BULLET_RE.match(line):<br>            list_lines = []<br>            while i &lt; n and BULLET_RE.match(lines[i]):<br>                list_lines.append(lines[i])<br>                i += 1<br>            blocks.append((&quot;bullet_list&quot;, list_lines))<br>            continue<br>        if ORDERED_RE.match(line):<br>            list_lines = []<br>            while i &lt; n and ORDERED_RE.match(lines[i]):<br>                list_lines.append(lines[i])<br>                i += 1<br>            blocks.append((&quot;ordered_list&quot;, list_lines))<br>            continue<br>        para_lines = [line]<br>        i += 1<br>        while i &lt; n and lines[i].strip() != &quot;&quot; and not BLOCK_START_RE.match(lines[i]):<br>            para_lines.append(lines[i])<br>            i += 1<br>        blocks.append((&quot;paragraph&quot;, para_lines))<br>    return blocks</pre><p>Every branch does the same three things: recognize a line pattern, consume however many following lines belong to that block, and append a tagged tuple describing it. Order matters — fenced code blocks are checked first, so a stray # or | character <em>inside</em> a code block is never mistaken for a heading or a table.</p><p>Each tagged block then flows through a small dispatcher that maps the tag to its own builder function:</p><pre>def block_to_adf(block):<br>    kind = block[0]<br>    if kind == &quot;heading&quot;:<br>        m = HEADING_RE.match(block[1])<br>        level = len(m.group(1))<br>        return {&quot;type&quot;: &quot;heading&quot;, &quot;attrs&quot;: {&quot;level&quot;: level},<br>                &quot;content&quot;: parse_inline(m.group(2)) or [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;&quot;}]}<br>    if kind == &quot;rule&quot;:<br>        return {&quot;type&quot;: &quot;rule&quot;}<br>    if kind == &quot;table&quot;:<br>        return block_table(block[1])<br>    if kind == &quot;bullet_list&quot;:<br>        return block_list(block[1], ordered=False)<br>    if kind == &quot;task_list&quot;:<br>        return block_task_list(block[1])<br>    if kind == &quot;ordered_list&quot;:<br>        return block_list(block[1], ordered=True)<br>    if kind == &quot;panel&quot;:<br>        return block_panel(block[1], block[2])<br>    if kind == &quot;code&quot;:<br>        _, lang, code_lines = block<br>        node = {&quot;type&quot;: &quot;codeBlock&quot;, &quot;content&quot;: [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;\n&quot;.join(code_lines)}]}<br>        if lang:<br>            node[&quot;attrs&quot;] = {&quot;language&quot;: lang}<br>        return node<br>    # paragraph<br>    text = &quot; &quot;.join(l.strip() for l in block[1])<br>    content = parse_inline(text) or ([{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: text}] if text else [])<br>    return {&quot;type&quot;: &quot;paragraph&quot;, &quot;content&quot;: content}</pre><p>And the whole pipeline collapses into one top-level function:</p><pre>def markdown_to_adf(md_text):<br>    blocks = split_into_blocks(md_text)<br>    return {&quot;type&quot;: &quot;doc&quot;, &quot;version&quot;: 1,<br>            &quot;content&quot;: [block_to_adf(b) for b in blocks]}</pre><p>Split into blocks, convert each block, wrap the results in a doc envelope. Each individual builder — table, lists, task lists, panels — is a small, single-purpose function, which is what made it manageable to add ADF&#39;s less obvious node types (panels, task lists) without the whole thing turning into a tangle.</p><h3>Supported Markdown subset</h3><p>MarkdownADF node/mark# .. ######heading (levels 1–6)**bold**strong mark*italic* / _italic_em mark`code`code mark[text](url)link mark- item / * itembulletList1. itemorderedList- [ ] item / - [x] itemtaskList (real, clickable Jira checkboxes)| a | b | + |---|---|table (real grid, styled header row)```lang ... ```codeBlock---rule:::panel warning ... :::panel (custom syntax — see below)</p><p>One deliberate limitation: nested or overlapping inline marks (bold <em>inside</em> a link, for instance) aren’t supported. Inline formatting stays flat. It’s a small cost for a converter that has no dependencies and fits in one file.</p><p>The core of the inline parser is a single regex that tries each mark in order and slices the string around whatever it finds:</p><pre>INLINE_RE = re.compile(<br>    r&quot;\*\*(?P&lt;bold&gt;.+?)\*\*&quot;<br>    r&quot;|`(?P&lt;code&gt;.+?)`&quot;<br>    r&quot;|\*(?P&lt;italic&gt;.+?)\*&quot;<br>    r&quot;|_(?P&lt;italic2&gt;.+?)_&quot;<br>    r&quot;|\[(?P&lt;linktext&gt;[^\]]+)\]\((?P&lt;linkurl&gt;[^)]+)\)&quot;<br>)</pre><pre>def parse_inline(text):<br>    nodes = []<br>    pos = 0<br>    for m in INLINE_RE.finditer(text):<br>        if m.start() &gt; pos:<br>            plain = text[pos:m.start()]<br>            if plain:<br>                nodes.append({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: plain})<br>        if m.group(&quot;bold&quot;) is not None:<br>            nodes.append({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: m.group(&quot;bold&quot;),<br>                          &quot;marks&quot;: [{&quot;type&quot;: &quot;strong&quot;}]})<br>        elif m.group(&quot;code&quot;) is not None:<br>            nodes.append({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: m.group(&quot;code&quot;),<br>                          &quot;marks&quot;: [{&quot;type&quot;: &quot;code&quot;}]})<br>        # ...italic, link handled the same way<br>        pos = m.end()<br>    if pos &lt; len(text):<br>        nodes.append({&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: text[pos:]})<br>    return nodes</pre><p>Nothing exotic — it’s the block-level splitting (headings, tables, lists, fenced code, panels) that does the real structural work, walking the document line by line and grouping runs of related lines into blocks before handing each block to its own converter function.</p><h3>Colored panels: the nicest surprise in the ADF schema</h3><p>ADF has a panel node type that matches, one-to-one, the five panel types in Jira&#39;s own comment-editor dropdown: Info, Note, Success, Warning, Error — each with its own icon and background color (blue, purple, green, yellow, red respectively).</p><p>Markdown has no native syntax for a colored callout box, so the script invents a small one:</p><pre>:::panel warning<br>This is a **warning panel** — inline formatting still works inside it.<br>:::</pre><p>That gets parsed into:</p><pre>{<br>  &quot;type&quot;: &quot;panel&quot;,<br>  &quot;attrs&quot;: { &quot;panelType&quot;: &quot;warning&quot; },<br>  &quot;content&quot;: [<br>    {<br>      &quot;type&quot;: &quot;paragraph&quot;,<br>      &quot;content&quot;: [<br>        { &quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;This is a &quot; },<br>        {<br>          &quot;type&quot;: &quot;text&quot;,<br>          &quot;text&quot;: &quot;warning panel&quot;,<br>          &quot;marks&quot;: [{ &quot;type&quot;: &quot;strong&quot; }]<br>        },<br>        {<br>          &quot;type&quot;: &quot;text&quot;,<br>          &quot;text&quot;: &quot; — inline formatting still works inside it.&quot;<br>        }<br>      ]<br>    }<br>  ]<br>}</pre><p>The panelType attribute has to be one of info, note, success, warning, or error — anything else is treated as a script error rather than silently ignored, which is the right failure mode for a formatting tool: better to fail loud than post a broken panel.</p><p>The builder function is a two-line recursion — it re-runs the <em>same</em> block splitter on the lines between :::panel warning and :::, so a panel can contain anything a top-level document can (a paragraph, a list, even a table), not just plain text:</p><pre>def block_panel(panel_type, inner_lines):<br>    inner_blocks = split_into_blocks(&quot;\n&quot;.join(inner_lines))<br>    content = [block_to_adf(b) for b in inner_blocks] or [{&quot;type&quot;: &quot;paragraph&quot;, &quot;content&quot;: []}]<br>    return {&quot;type&quot;: &quot;panel&quot;, &quot;attrs&quot;: {&quot;panelType&quot;: panel_type}, &quot;content&quot;: content}</pre><h3>Task lists: real checkboxes, and a bug that only showed up live</h3><p>GFM-style - [ ] / - [x] becomes a real, independently clickable Jira checklist — a taskList node containing taskItem nodes with an attrs.state of &quot;TODO&quot; or &quot;DONE&quot;.</p><p>Here’s where it got interesting. The first version of the script generated each taskItem&#39;s required localId from a simple incrementing counter: local-1, local-2, and so on. I posted a checklist with one item checked and one unchecked. Both came back <strong>checked</strong>.</p><p>The root cause turned out to be genuinely surprising: task-item checked/unchecked state in Jira and Confluence isn’t purely a property of the document content sitting in that one comment. It’s synced through a realtime “tasks and decisions” service that’s keyed by localId — and that key is <strong>site-wide</strong>, not scoped to the specific comment or document that declared it.</p><p>A low-cardinality, guessable ID like &quot;local-1&quot; can collide with some completely unrelated task elsewhere on the same Jira site. When that happens, the new task item silently inherits <em>that</em> other task&#39;s checked state instead of the one it was actually posted with.</p><p>The fix was a one-line change:</p><pre>def _local_id():<br>    # Task-item checked/unchecked state is tracked by Jira&#39;s realtime &quot;tasks<br>    # and decisions&quot; service keyed on localId, SITE-WIDE, not scoped to this<br>    # comment/doc -- a low-cardinality id like &quot;local-1&quot; can collide with an<br>    # unrelated task elsewhere and silently inherit its checked state.<br>    # A real UUID makes collision practically impossible.<br>    return str(uuid.uuid4())</pre><p>Swapping a sequential counter for uuid.uuid4() per task item makes collision practically impossible. What makes this worth calling out is that it&#39;s not the kind of bug you&#39;d catch from reading the ADF schema documentation — the schema doc will happily tell you localId is &quot;a unique identifier&quot; without telling you <em>how site-wide</em> that uniqueness requirement actually is. It only surfaced by posting a real checklist to a real ticket and looking at what rendered.</p><p>Here’s the full builder, with the fix in place — note that both the taskList wrapper <em>and</em> every taskItem inside it need their own localId:</p><pre>def block_task_list(lines):<br>    items = []<br>    for line in lines:<br>        m = TASK_RE.match(line.strip())<br>        checked, text = m.group(1), m.group(2)<br>        state = &quot;DONE&quot; if checked.lower() == &quot;x&quot; else &quot;TODO&quot;<br>        items.append({&quot;type&quot;: &quot;taskItem&quot;, &quot;attrs&quot;: {&quot;localId&quot;: _local_id(), &quot;state&quot;: state},<br>                       &quot;content&quot;: parse_inline(text)})<br>    return {&quot;type&quot;: &quot;taskList&quot;, &quot;attrs&quot;: {&quot;localId&quot;: _local_id()}, &quot;content&quot;: items}</pre><h3>Bare URLs don’t auto-link</h3><p>[text](url) becomes a real, clickable link mark, exactly as you&#39;d expect. A bare https://... pasted directly into the Markdown source, however, does <strong>not</strong> turn into a link — the inline-parsing regex only matches the explicit [text](url) syntax, so an unwrapped URL is left as plain text in the output.</p><p>The practical rule: always wrap URLs explicitly, even when it feels redundant —</p><pre>[PR #482](https://github.com/your-org/your-repo/pull/482)</pre><p>rather than pasting the raw link and hoping the renderer is smart about it.</p><h3>Seeing it rendered before you post</h3><p>You don’t have to post to a real ticket just to check whether your ADF is correct. Atlassian publishes two small tools on its developer site for exactly this:</p><ul><li><strong>Document builder</strong> — a WYSIWYG editor where you type/format content and watch the ADF JSON it produces update live. Useful for reverse-engineering “what does node X look like,” but it does <em>not</em> interpret Markdown — type **bold** into it and you get the literal string **bold**, not a bold mark, because it&#39;s building ADF from rich-text editing, not parsing syntax.</li><li><strong>Document viewer</strong> — the tool that actually matters here. Paste in ADF JSON and it renders it the same way Jira’s own editor would: panels, tables, task lists, colors and all. This is the fastest way to sanity-check a converter’s output before spending an API call on it.</li></ul><p>Here’s a single Markdown source file that exercises most of what the converter supports — a heading, inline bold/code, a warning panel, a table, an ordered list, a task list with both checked and unchecked items, an explicit link, a rule, and a second panel:</p><pre># Root Cause: Sync Worker Retry Storm</pre><pre>The `sync` worker was retrying failed jobs **without backoff**, which amplified load during the outage instead of easing it.</pre><pre>:::panel warning<br>This fix changes retry timing defaults. Any service still reading the old `RETRY_DELAY_MS` env var will fall back to the new default of `2000` instead of `500`.<br>:::</pre><pre>## Findings</pre><pre>| Metric      | Before | After |<br>| ----------- | ------ | ----- |<br>| Retries/min | 4200   | 180   |<br>| p95 latency | 3.1s   | 640ms |<br>| Error rate  | 12%    | 0.4%  |</pre><pre>## Fix steps</pre><pre>1. Add exponential backoff to `retry_job()`<br>2. Cap max retries at `5`<br>3. Emit a `retry_exhausted` metric for alerting</pre><pre>## Acceptance criteria</pre><pre>- [x] Backoff verified in staging load test<br>- [x] Metric visible in dashboard<br>- [ ] Rollout to production behind feature flag</pre><pre>See [PR #482](https://github.com/your-org/your-repo/pull/482) for the implementation.</pre><pre>---</pre><pre>:::panel note<br>Root cause and fix verified together in staging; production rollout is tracked separately.<br>:::</pre><p>Running it through the converter with --dry-run produces a full ADF doc tree — headings, the panel nodes with their panelType attrs, the table with a styled header row, the orderedList, a taskList with per-item UUID localIds, and a link mark on the PR reference. Pasting that JSON into Document viewer renders it exactly as it would look inside a real Jira comment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/970/1*6kRsPBefUy5YzqqTR5A6AQ.png" /><figcaption>Warning panel preview</figcaption></figure><p>The table comes out as a real grid with a styled header row, not the pipe-and-dash text it started as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/986/1*E8Y9H_SGSPBKY2EoBppUkA.png" /><figcaption>Table Preview</figcaption></figure><p>And the task list renders as genuine, independently-checked/unchecked boxes, the link is clickable, and the closing panel picks up its own icon and background color for the note type:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/988/1*CEwWUzk7I1Fb4q0pf58jYg.png" /><figcaption>Tasklist Note Preview</figcaption></figure><p>That’s the whole workflow in practice: write Markdown, run --dry-run, paste into Document viewer, confirm it looks right, then drop --dry-run and let the script post it for real.</p><h3>Beyond comments: overwriting a whole field</h3><p>Comments are additive by nature — POST /issue/{key}/comment never touches anything that&#39;s already there. Sometimes what you actually want is to <em>replace</em> a field instead, most commonly a ticket&#39;s own description, which tools that create issues through simpler APIs often populate as flat text with hand-rolled section dividers like === Root Cause === instead of real structure.</p><p>The script supports this with a --field flag, which instead sends:</p><pre>PUT /rest/api/3/issue/{key}<br>Body: {&quot;fields&quot;: {&quot;description&quot;: &lt;adf-json&gt;}}</pre><p>This call <strong>overwrites the field wholesale</strong> — there is no partial-update or append mode for a rich-text ADF field, so it’s a genuinely different operation from posting a comment, not a variant of it.</p><p>Used correctly, it turns an old plain-text description — dividers and all — into a real document: headings, an ordered list, a couple of panels (a note for context, a warning for a known limitation), and a task list for acceptance criteria. A follow-up GET on the issue confirms it worked: the updated timestamp moves, and the field now contains a proper ADF document instead of a string with === in it.</p><h3>Auth and the two HTTP calls</h3><p>Both operations share the same Basic-auth header, built from an account email and an API token:</p><pre>def resolve_auth(args):<br>    base_url = args.base_url or os.environ.get(&quot;JIRA_BASE_URL&quot;)<br>    if not base_url:<br>        site = args.site or os.environ.get(&quot;JIRA_SITE&quot;)<br>        if site:<br>            base_url = f&quot;https://{site}.atlassian.net&quot;<br>    email = args.email or os.environ.get(&quot;JIRA_EMAIL&quot;)<br>    token = args.token or os.environ.get(&quot;JIRA_API_TOKEN&quot;)<br>    missing = [name for name, value in (<br>        (&quot;JIRA_BASE_URL / --base-url (or JIRA_SITE / --site)&quot;, base_url),<br>        (&quot;JIRA_EMAIL / --email&quot;, email),<br>        (&quot;JIRA_API_TOKEN / --token&quot;, token),<br>    ) if not value]<br>    if missing:<br>        sys.exit(&quot;Missing Jira auth: &quot; + &quot;, &quot;.join(missing))<br>    return base_url.rstrip(&quot;/&quot;), email, token<br></pre><pre>def _auth_header(email, token):<br>    basic = base64.b64encode(f&quot;{email}:{token}&quot;.encode(&quot;utf-8&quot;)).decode(&quot;ascii&quot;)<br>    return {&quot;Authorization&quot;: f&quot;Basic {basic}&quot;,<br>            &quot;Content-Type&quot;: &quot;application/json&quot;,<br>            &quot;Accept&quot;: &quot;application/json&quot;}</pre><p>Posting a comment and overwriting a field are then two thin wrappers around urllib.request — no HTTP client dependency needed for two calls:</p><pre>def post_comment(base_url, email, token, issue_key, adf_body):<br>    url = f&quot;{base_url}/rest/api/3/issue/{issue_key}/comment&quot;<br>    payload = json.dumps({&quot;body&quot;: adf_body}).encode(&quot;utf-8&quot;)<br>    req = urllib.request.Request(url, data=payload, method=&quot;POST&quot;,<br>                                  headers=_auth_header(email, token))<br>    try:<br>        with urllib.request.urlopen(req) as resp:<br>            return json.loads(resp.read().decode(&quot;utf-8&quot;))<br>    except urllib.error.HTTPError as e:<br>        sys.exit(f&quot;Jira API error {e.code}: {e.read().decode(&#39;utf-8&#39;, errors=&#39;replace&#39;)}&quot;)<br></pre><pre>def update_field(base_url, email, token, issue_key, field_name, adf_body):<br>    url = f&quot;{base_url}/rest/api/3/issue/{issue_key}&quot;<br>    payload = json.dumps({&quot;fields&quot;: {field_name: adf_body}}).encode(&quot;utf-8&quot;)<br>    req = urllib.request.Request(url, data=payload, method=&quot;PUT&quot;,<br>                                  headers=_auth_header(email, token))<br>    try:<br>        with urllib.request.urlopen(req) as resp:<br>            resp.read()  # 204 No Content on success -- nothing to parse<br>    except urllib.error.HTTPError as e:<br>        sys.exit(f&quot;Jira API error {e.code}: {e.read().decode(&#39;utf-8&#39;, errors=&#39;replace&#39;)}&quot;)</pre><p>And main() just wires the pieces together — read the file, convert it, then decide which of the three modes to run:</p><pre>def main():<br>    args = parse_args()  # issue_key, markdown_file, --dry-run, --field, --site, --email, --token<br>    md_text = sys.stdin.read() if args.markdown_file == &quot;-&quot; \<br>        else open(args.markdown_file, encoding=&quot;utf-8&quot;).read()<br>    adf = markdown_to_adf(md_text)</pre><pre>    if args.dry_run:<br>        print(json.dumps(adf, indent=2, ensure_ascii=False))<br>        return</pre><pre>    base_url, email, token = resolve_auth(args)<br>    if args.field:<br>        update_field(base_url, email, token, args.issue_key, args.field, adf)<br>        print(f&quot;Field updated ({args.field}): {base_url}/browse/{args.issue_key}&quot;)<br>    else:<br>        result = post_comment(base_url, email, token, args.issue_key, adf)<br>        print(f&quot;Comment posted: {base_url}/browse/{args.issue_key}?focusedCommentId={result.get(&#39;id&#39;)}&quot;)</pre><p>That’s the entire tool: one pass to turn Markdown into an ADF tree, one small auth/HTTP layer to send it, and a three-way branch (--dry-run / --field / plain comment) to decide what to do with the result.</p><h3>Windows-specific gotchas</h3><p>A handful of friction points are worth a short aside, even though they’re incidental to the Jira/ADF problem itself:</p><ul><li>python3 isn&#39;t on PATH in Git Bash by default on Windows — it silently resolves to a Microsoft Store stub. Use the full interpreter path instead (e.g. .../AppData/Local/Programs/Python/Python311/python.exe).</li><li>Printing the --dry-run JSON straight to a Windows console can throw a UnicodeEncodeError on emoji, because the default console codepage is cp1252, not UTF-8. Set PYTHONIOENCODING=utf-8, or redirect the output to a file and read that back instead.</li><li>Running the script from a UNC path (\\wsl.localhost\...) while also passing a UNC path as an argument can double up the path, because the process&#39;s own working directory gets prefixed onto an already-absolute path. Simplest fix: copy both the script and the input file to a plain local path first.</li><li>Occasional RemoteDisconnected errors on the POST call — a transient network reset, not something specific to this script. The request can still have succeeded server-side even though the client-side response read failed, so a naive &quot;retry on any error&quot; loop can end up double-posting. Check the target ticket for an already-landed comment before retrying.</li></ul><h3>FAQ</h3><p><strong>Why doesn’t Markdown work in Jira comments?</strong></p><p>Because the comment API doesn’t parse Markdown (or wiki-markup) at all. It posts whatever string it receives as a single literal ADF text node, so any formatting syntax you write shows up as plain characters.</p><p><strong>What is Atlassian Document Format (ADF)?</strong></p><p>ADF is the JSON document tree Jira and Confluence use internally to represent rich text — the same structure their own ProseMirror-based editors produce when a person types in the browser. Headings, bold/italic, tables, lists, panels, and more each map to a specific ADF node or mark type.</p><p><strong>How do I post formatted content to Jira via the API?</strong></p><p>Build an ADF JSON document and POST it to the REST API <strong>v3</strong> comment endpoint (/rest/api/3/issue/{key}/comment), not v2, which only accepts plain strings or wiki-markup.</p><p><strong>Can I preview ADF output without posting to a real Jira ticket?</strong></p><p>Yes — paste the ADF JSON into Atlassian’s Document Viewer developer tool, which renders it exactly as Jira’s own editor would, without touching a live ticket.</p><p><strong>Does Jira support Markdown at all?</strong></p><p>Jira’s newer editor accepts some Markdown-like shortcuts when a human types them interactively (e.g. typing ** around text), but that&#39;s editor-level autoformatting, not something the REST API applies to a string you send it programmatically.</p><p><strong>Why did my Jira task-list checkboxes show the wrong checked state?</strong></p><p>If taskItem nodes use low-cardinality, sequential localId values, they can collide with unrelated tasks elsewhere on the same Jira site, because checkbox state is tracked by a site-wide realtime service keyed on that id — not scoped to your document. Use a real UUID per task item instead.</p><h3>The takeaway</h3><p>A few hundred lines of dependency-free Python turn Jira comments — and, with --field, whole issue fields — from &quot;flat text wearing Markdown as a costume&quot; into the same rich content a human gets from the real editor: tables, task lists, colored panels, clickable links, all driven entirely by an agent or script writing plain Markdown.</p><p>The interesting part isn’t really the ADF schema itself — that’s just JSON, and it’s reasonably well documented by Atlassian. The interesting part is that the <em>state</em> behind something as simple as a checkbox turned out not to live where it looks like it lives — not in the document you just posted, but in a site-wide realtime service keyed by an ID you control. And the only way to find that out was to actually post something to a real ticket and look carefully at what rendered.</p><p>If you’re building any kind of bot, agent, or integration that writes to Jira, that’s the general lesson worth taking away: read the schema, but don’t trust it as the full picture. Post something small, look at the result, and let the live behavior correct your mental model.</p><p><em>Tags: Jira, Atlassian, API, Python, Developer Tools, Automation, JSON</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=df852cdc971c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Automatic Term Extraction: The Research Field Behind Your Keyword Extractor]]></title>
            <link>https://medium.com/@huseyinceniik/automatic-term-extraction-the-research-field-behind-your-keyword-extractor-f212338829b8?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/f212338829b8</guid>
            <category><![CDATA[naturallanguageprocessing]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[keyword-research]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Sat, 25 Jul 2026 09:25:33 GMT</pubDate>
            <atom:updated>2026-07-25T09:25:33.627Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Keyword extractors tell you what a document is about. Term extractors tell you what a domain is made of. A survey of the ATE literature — from the 1998 C-value algorithm to 2025 LLM prompting — with a working Python implementation, benchmark results, and a decision guide</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8qYJP_Xr_w3yjXBilQnA7A.png" /><figcaption>Automatic Term Extraction (ATE): Mapping the domain vocabulary</figcaption></figure><p>Search for “keyword extraction in Python” and you will find hundreds of tutorials: <strong>TF-IDF with scikit-learn, RAKE, YAKE, TextRank, KeyBERT.</strong> Search for “automatic term extraction” and you will find almost nothing outside the academic literature — where the task has a formal definition, a manually annotated multilingual benchmark, three survey papers published between 2023 and 2026, and an active research frontier.</p><p>This asymmetry is unfortunate, because the two tasks are not the same, and the harder one is better studied. Automatic Term Extraction (ATE) is, in the words of the field’s most recent survey, “a Natural Language Processing (NLP) task intended to automatically identify specialized terms present in domain-specific corpora,” where terms function “as units of knowledge in a specific field of expertise” (Tran et al., 2026). The output is not a per-document summary; it is a <strong>termbase</strong> — the vocabulary that constitutes a domain.</p><p>This article surveys the field and implements it. We derive the C-value algorithm from first principles, build a complete extraction pipeline in dependency-free Python, run it on a wind-energy corpus, and then subject the output to an honest error analysis. We then trace what the research literature has established since: the ACTER benchmark that made ATE systems comparable, the Transformer models that displaced feature engineering, and the recent and genuinely nuanced findings about where large language models help and where they do not.</p><p><strong>Principal findings:</strong></p><ul><li>Terms and keywords answer different questions; conflating them produces systems that optimise the wrong objective.</li><li>The C-value algorithm (Frantzi et al., 2000) remains in use in <strong>50% of published ATE studies</strong>, twenty-five years after its introduction (Blandón Andrade et al., 2026).</li><li>Transformer token classification is the current state of the art, reaching <strong>58.3–69.6 F1</strong> on the ACTER benchmark against <strong>54.2–66.1</strong> for the best feature-engineered system (Tran et al., 2023).</li><li>LLM in-context learning matches fine-tuned encoders under domain shift (60.2 vs. 61.2 F1) but trails them badly in-domain (53.8 vs. 88.5 F1) (Chun et al., 2025).</li><li>The nested-term problem, diagnosed in 1998, remains an open challenge for neural systems in 2026.</li></ul><blockquote><strong><em>▶ Run the code while you read.</em></strong><em> Every pipeline stage in this article is worked through cell by cell in the companion notebook: </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><strong><em>Automatic Term Extraction from Scratch</em></strong></a><em> on Kaggle · </em><a href="https://github.com/huseyincenik/automatic_term_extraction"><em>source on GitHub</em></a><em>. The tables and figures below are its actual output.</em></blockquote><h3>1. Terms Are Not Keywords</h3><p>The distinction is not pedantic; it determines the entire system design.</p><p><strong>Keyword extraction</strong> operates on a single document and asks: which words best summarise this text? The reference point is the document itself. TF-IDF answers it by contrasting the document against the rest of the collection; KeyBERT answers it by measuring the cosine similarity between candidate phrases and the document embedding. Both are, structurally, summarisation signals.</p><p><strong>Term extraction</strong> operates on a domain corpus and asks: which expressions constitute the specialised vocabulary of this field? The reference point is general language. A term such as <em>doubly fed induction generator</em> is a term regardless of which wind-energy document it appears in, and regardless of whether it summarises that document.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8_wy655mnhTBd8ZQQDpfiA.png" /><figcaption><em>Table 1. Synthesis by the author, drawing on Tran et al. (2023) and Rigouts Terryn et al. (2020) for the ATE column.</em></figcaption></figure><p>The consequences are concrete. A TF-IDF pipeline over a unigram vocabulary cannot, in principle, emit <em>doubly fed induction generator</em> as a single unit. A KeyBERT ranking will favour phrases that summarise a document over phrases that define a field. Neither models the relationship between <em>wind farm</em> and <em>offshore wind farm</em>.</p><h3>1.1 What counts as a term?</h3><p>The question resists a purely theoretical answer, which is why the ACTER annotation scheme is valuable: it operationalises the definition into categories that human annotators can apply consistently (Rigouts Terryn et al., 2020).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HqvC4_vOWnJW0RcHqYsBJQ.png" /><figcaption><em>Table 2. Source: Rigouts Terryn, Hoste, Drouin &amp; Lefever (2020), TermEval 2020, COMPUTERM @ LREC 2020, pp. 85–94.</em></figcaption></figure><p>This four-way split explains a convention that otherwise looks arbitrary in the literature: ATE papers report two sets of scores, one for <em>terms only</em> and one for <em>terms plus named entities</em>. Named-entity recognition and term extraction overlap without coinciding, and the benchmark refuses to pretend otherwise.</p><h3>2. The Canonical Pipeline</h3><p>Nearly all classical ATE systems share a two-stage architecture: a <strong>linguistic filter</strong> that proposes syntactically plausible candidates, followed by <strong>statistical ranking</strong> that scores them. The systematic literature mapping of Blandón Andrade et al. (2026) found this hybrid design in 50% of the 113 papers they reviewed, against 38.2% purely statistical and 11.8% purely linguistic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XEfmPFYphIW92WAoTkDn-g.png" /><figcaption><em>Figure 1. Pipeline after Frantzi et al. (2000); adoption statistic from Blandón Andrade et al. (2026).</em></figcaption></figure><p>The ranking stage is conventionally decomposed into two distinct properties:</p><ul><li><strong>Unithood</strong> — is this sequence of words a coherent lexical unit, or an accidental collocation?</li><li><strong>Termhood</strong> — assuming it is a unit, is it specific to this domain, or general language?</li></ul><p>C-value addresses unithood. Weirdness and TF-IDF address termhood. Systems that measure only one produce characteristic failures: unithood alone accepts frequent but general phrases; termhood alone accepts fragments of terms.</p><h3>3. Stage One: The Linguistic Filter</h3><p>Frantzi et al. (2000) restrict candidates to part-of-speech patterns, offering three filters of increasing permissiveness:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YN9-4YxG54kGYO93A3EqYg.png" /><figcaption><em>Source: Frantzi, Ananiadou &amp; Mima (2000), International Journal on Digital Libraries 3(2), 115–130.</em></figcaption></figure><p>The choice is a precision/recall dial. The closed filter yields fewer, cleaner candidates; the open filter captures prepositional terms such as <em>quality of service</em> at the cost of substantial noise.</p><p>For a dependency-free implementation we approximate the POS filter with a stopword-boundary chunker, which exploits the fact that English terms rarely span a function word. Within each stopword-free chunk of a sentence, every n-gram up to length four becomes a candidate:</p><pre>def candidates(text, min_len=1, max_len=4):<br>    &quot;&quot;&quot;Inside each stopword-free chunk of a sentence, emit every n-gram.&quot;&quot;&quot;<br>    out = []<br>    for sentence in SENT_SPLIT_RE.split(text):<br>        chunk = []<br>        for token in tokenize(sentence) + [None]:   # None flushes the chunk<br>            if token is None or token in STOPWORDS or len(token) &lt; 3:<br>                for n in range(min_len, max_len + 1):<br>                    for i in range(len(chunk) - n + 1):<br>                        out.append(tuple(chunk[i:i + n]))<br>                chunk = []<br>            else:<br>                chunk.append(token)<br>    return out</pre><p>This is the same candidate-generation strategy RAKE uses. The difference between RAKE and ATE lies entirely in what happens next.</p><h3>4. Stage Two: C-value and the Nested-Term Problem</h3><h3>4.1 The problem</h3><p>Raw frequency systematically over-ranks substrings. If <em>offshore wind farm</em> occurs twice, then <em>wind farm</em> necessarily occurs at least twice as well, and a frequency-ranked list will credit the shorter string with occurrences it never independently earned. Frantzi et al. (2000) named these <strong>nested terms</strong> and designed C-value specifically to handle them.</p><h3>4.2 The formula</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MT0qJ7aNTX0bOlij4T6x-w.png" /><figcaption><em>Source: Frantzi, Ananiadou &amp; Mima (2000), International Journal on Digital Libraries 3(2), 115–130.</em></figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/674/1*e1SU9AqtRrpCTbNvuvwZ8w.png" /><figcaption><em>Source: Frantzi, Ananiadou &amp; Mima (2000), International Journal on Digital Libraries 3(2), 115–130.</em></figcaption></figure><p>Three mechanisms operate simultaneously. The log₂|a| factor rewards length, encoding the empirical observation that multi-word expressions are more likely to be terms than single words. The f(a) term preserves frequency as evidence. The subtraction removes the frequency that a merely inherited from longer terms containing it — the average frequency of every longer candidate in which a is nested.</p><h3>4.3 Implementation</h3><pre>def c_value(freqs, min_freq=2):<br>    longer_containing = defaultdict(list)<br>    by_len = defaultdict(list)<br>    for cand in freqs:<br>        by_len[len(cand)].append(cand)</pre><pre>    for length, shorter_list in by_len.items():<br>        for shorter in shorter_list:<br>            for longer_len in range(length + 1, MAX_LEN + 1):<br>                for longer in by_len.get(longer_len, ()):<br>                    if _contains(longer, shorter):<br>                        longer_containing[shorter].append(freqs[longer])</pre><pre>    scores = {}<br>    for cand, freq in freqs.items():<br>        if freq &lt; min_freq:<br>            continue<br>        length_weight = math.log2(len(cand)) if len(cand) &gt; 1 else 0.5<br>        nested_in = longer_containing.get(cand)<br>        if nested_in:<br>            score = length_weight * (freq - sum(nested_in) / len(nested_in))<br>        else:<br>            score = length_weight * freq<br>        if score &gt; 0:<br>            scores[cand] = score<br>    return scores</pre><h3>4.4 The correction, observed</h3><p>Running this on the wind-energy corpus makes the mechanism visible.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*or9JMyjqfXva6OGgR7EgVQ.png" /><figcaption><em>Figure 2. Computed in the </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>companion notebook</em></a><em>; C-value after Frantzi et al. (2000).</em></figcaption></figure><p>Consider the three candidates <em>wind farm</em>, <em>offshore wind farm</em> and <em>axis wind</em>. All three occur; frequency alone ranks <em>wind farm</em> (f = 5) far above the others. C-value redistributes the evidence: <em>wind farm</em> retains a score of 3.86 rather than 5, because two of its five occurrences were inside <em>offshore wind farm</em>. Meanwhile <em>axis wind</em> — which in this corpus appears almost exclusively inside <em>axis wind turbine</em> — is discounted from f = 2 to a C-value of 0.75, and falls to the bottom of the ranking. The algorithm has, without supervision, distinguished an independent term from a fragment.</p><h3>4.5 NC-value: the context extension</h3><p>Frantzi et al. extend C-value with context evidence. Term context words are learned from the top-ranked candidates and weighted by the proportion of candidate terms they co-occur with, and the final score combines the two sources of evidence:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QBnly-f0IJMq_IGBzWh_AQ.png" /><figcaption><em>Source: Frantzi, Ananiadou &amp; Mima (2000), International Journal on Digital Libraries 3(2), 115–130.</em></figcaption></figure><p>Here t(w) is the number of candidate terms that context word w appears with, n the total number of candidates considered, Cₐ the set of distinct context words of a, and f_a(b) the frequency of b as a context word of a.</p><p>The hand-tuned 0.8/0.2 weighting is a period detail worth noting: it illustrates how differently NLP systems were constructed before end-to-end learning.</p><h3>5. Stage Three: Termhood via Contrastive Scoring</h3><p>C-value measures unithood but is blind to domain specificity. A phrase can be a perfectly coherent unit and still be general language. The standard remedy is <strong>weirdness</strong> (Ahmad et al.), which contrasts the technical corpus against a general-language reference:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3_Uiu7sezXXWSz-C3w0eSA.png" /><figcaption><em>Source: Ahmad, Gillam &amp; Tostevin — weirdness / corpus-comparison indexing.</em></figcaption></figure><pre>def weirdness(tech_tokens, general_tokens, smoothing=0.5):<br>    f_tech, f_gen = Counter(tech_tokens), Counter(general_tokens)<br>    n_tech, n_gen = max(len(tech_tokens), 1), max(len(general_tokens), 1)<br>    return {<br>        w: (f / n_tech) / ((f_gen[w] + smoothing) / n_gen)<br>        for w, f in f_tech.items()<br>    }</pre><p>For multi-word candidates the word-level scores are aggregated by geometric mean, so that a single anomalous word cannot carry an entire phrase. The final termhood score combines both dimensions, with a logarithmic dampener so that weirdness re-ranks rather than dominates:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ghPB2A0iJKWuFgahOOAbcg.png" /><figcaption><em>Combination used by the </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>companion notebook</em></a><em>; not taken from a published system.</em></figcaption></figure><pre>termhood = c_value_score * math.log1p(weirdness_score)</pre><p>Plotting the two axes against each other shows the structure the pipeline is exploiting.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9XMag6mXnMYbv7fYboRwfg.png" /><figcaption><em>Figure 3. Computed in the </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>companion notebook</em></a><em>; C-value after Frantzi et al. (2000), weirdness after Ahmad et al.</em></figcaption></figure><p>Valid multi-word terms — <em>wind turbine</em>, <em>wind farm</em>, <em>wind speed</em> — occupy the upper-right quadrant, scoring well on both dimensions. General vocabulary such as <em>power</em> and <em>speed</em> achieves moderate unithood but low weirdness, and is correctly demoted. Fragments such as <em>axis wind</em> score low on unithood despite respectable weirdness. The two measures are complementary precisely because they fail in different directions.</p><h3>6. Use Case: Building a Wind-Energy Termbase</h3><h3>6.1 Scenario</h3><p>A technical translation provider receives several hundred pages of wind-energy documentation. Before translation begins, a termbase must be assembled so that every translator renders <em>pitch control</em> identically. Constructing this by hand occupies a terminologist for days. ATE produces the first draft in seconds.</p><p>The domain is not arbitrary: <strong>wind energy is one of the four ACTER domains</strong>, so results here map directly onto the standard benchmark.</p><h3>6.2 Results</h3><p>The complete implementation is dependency-free and runs on any Python 3 installation. Every step below — candidate generation, C-value, weirdness, and the error analysis in section 7 — is worked through cell by cell in the companion notebook:</p><blockquote><strong><em>▶ Run it yourself:</em></strong><em> </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>Automatic Term Extraction from Scratch on Kaggle</em></a><em> · </em><a href="https://github.com/huseyincenik/automatic_term_extraction"><em>source on GitHub</em></a></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YhIUtlBgSUXSB6WN3FKgxw.png" /><figcaption><em>Table 3. Computed in the </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>companion notebook</em></a><em>; scoring after Frantzi et al. (2000) and Ahmad et al. The assessment column is the author’s.</em></figcaption></figure><p>Seven of the top ten candidates are valid domain terms. Two exhibit <strong>boundary errors</strong> — <em>offshore wind</em> is a fragment of <em>offshore wind farm</em>, and <em>axis wind turbine</em> should be <em>horizontal axis wind turbine</em>. One, <em>axis wind</em>, is spurious.</p><p>A 70% precision rate at rank 10, achieved without training data, annotation, or any model larger than a Counter, is a reasonable result. But the failures are more instructive than the successes.</p><h3>7. Error Analysis</h3><p>Two systematic failure modes appear, both of which anticipate findings in the research literature.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QyjuvrWmE7jRpeUzKI1DgA.png" /><figcaption><em>Figure 4. Computed in the </em><a href="https://www.kaggle.com/code/huseyincenik/automatic-term-extraction"><em>companion notebook</em></a><em> on the wind-energy demonstration corpus.</em></figcaption></figure><p><strong>The frequency threshold destroys recall.</strong> The pipeline uses min_freq = 2, discarding every candidate occurring once. Panel A quantifies the cost: candidates fall from 98 at min_freq = 1 to 33 at min_freq = 2. Among the casualties are <em>pitch control</em>, <em>capacity factor</em>, <em>power curve</em> and <em>rotor diameter</em> — all genuine, high-value terms that happen to occur once in this corpus. Frequency-based methods are structurally incapable of recovering rare terms, and this is precisely the dimension on which TermEval 2020 found systems to differ most sharply: some participants were &quot;exceptionally good at finding rare terms&quot; while others were not (Rigouts Terryn et al., 2020).</p><p><strong>A term can also be lost before any scoring happens.</strong> <em>Yaw control</em> never appears in the output either, but for a different and more instructive reason: it is never generated as a candidate at all. A line break falls between <em>yaw</em> and <em>control</em> in the source text, and the sentence splitter treats \n as a boundary, so the bigram is never formed. A candidate the linguistic filter fails to propose cannot be recovered by any ranking function, however sophisticated — which is why the filter stage deserves as much attention as the algorithm, and why production systems use a real sentence segmenter rather than a regular expression.</p><p><strong>Single words crowd the ranking.</strong> Panel B shows that six of the top twelve candidates are unigrams, with <em>wind</em> occupying first place at more than twice the score of any multi-word term. This is why production ATE tools expose a have_single_word switch — PyATE included — and why the parameter is not a convenience but a necessity.</p><p>Two further observations deserve emphasis. First, the boundary errors in Table 3 are <strong>the nested-term problem re-emerging in the opposite direction</strong>: C-value successfully demotes <em>axis wind</em>, but nothing in the pipeline knows that <em>axis wind turbine</em> is itself incomplete. Second, weirdness is only as good as the contrast corpus, and a 200-word toy general corpus is far too small; production systems contrast against Wikipedia-scale references.</p><h3>8. Evaluation: The ACTER Benchmark</h3><p>Before 2020, ATE results were effectively incomparable. Each research group evaluated on its own corpus, with its own operational definition of “term.” TermEval 2020 changed this by releasing ACTER — the <em>Annotated Corpora for Term Extraction Research</em> — with the explicit aim of providing “a large, transparent, qualitatively annotated, and diverse dataset to the ATE research community” (Rigouts Terryn et al., 2020).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g4LJi8j9BJY5duJLUlPtkg.png" /><figcaption><em>Table 4. Source: ACTER v1.5 (Rigouts Terryn, Hoste &amp; Lefever, 2019); TermEval 2020 shared task — github.com/AylaRT/ACTER.</em></figcaption></figure><p>Two design decisions make ACTER effective as a benchmark. The <strong>heart-failure domain is held out entirely</strong>, so reported F1 measures cross-domain generalisation rather than in-domain fitting. And the corpus is <strong>multilingual by construction</strong> (English, French, Dutch), enabling the cross-lingual transfer experiments that dominate recent work. The corruption subcorpus is parallel; the remaining three are comparable corpora matched for size, topic and register.</p><p>Evaluation itself uses standard metrics against the gold annotation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SWFwa9wekOz78fobPD_BjA.png" /><figcaption><em>Source: Tran et al. (2023), arXiv:2301.06767, section 6.2 — standard ATE evaluation metrics.</em></figcaption></figure><p>Tran et al. (2023) note that historically “the number of studies using just Precision is higher than the number of studies that evaluate the system according to all three evaluation criteria,” a consequence of gold-standard scarcity. Systems evaluated on ACTER report all three.</p><h3>9. What the Literature Actually Uses</h3><p>Before turning to neural methods, it is worth establishing what practitioners have in fact adopted. Blandón Andrade et al. (2026) systematically mapped 113 ATE papers published between 2015 and 2022.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fHlwlcDrfDEx8T_6JDwNsg.png" /><figcaption><em>Figure 5. Data: Blandón Andrade et al. (2026), systematic literature mapping, DOI 10.1177/18758967251392652.</em></figcaption></figure><p>Two findings stand out. Hybrid linguistic-plus-statistical designs account for half of all studies, with purely linguistic approaches nearly extinct at 11.8%. And <strong>C-value alone appears in 50% of the reviewed work</strong>, with TF-IDF second at 31%. An algorithm published in 1998 was, through 2022, the single most widely used ranking function in its field.</p><p>The most-used tool was Python’s <strong>NLTK</strong> — a mild but real divergence from practitioner reality, where spaCy dominates and where the principal ATE library, PyATE, is built on spaCy’s POS tagger.</p><p>It bears repeating that this review window <strong>closes in 2022</strong>, immediately before the developments in the next two sections.</p><h3>10. The Neural Era: Transformer Token Classification</h3><p>Tran et al. (2023) surveyed 112 papers selected from an initial pool of 329, and reported comparative results on the ACTER held-out test set. The picture is unambiguous.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ar6X4Hbgj1jSaOyb6sIRsw.png" /><figcaption><em>Figure 6. Data: Tran et al. (2023), arXiv:2301.06767.</em></figcaption></figure><p>Three conclusions follow from these numbers.</p><p><strong>Neural models substantially outperform feature engineering.</strong> The survey states plainly that “neural models generally outperform machine learning models based on feature engineering by a large margin.” HAMLET — a Random Forest over 152 hand-designed features, and the strongest non-neural system by a wide margin — reaches 54.2–66.1 F1. The XLM-R token classifier reaches 58.3–69.6.</p><p><strong>How the task is framed matters more than model size.</strong> The same XLM-R encoder scores 45.2–48.5 F1 as a <em>sequence classifier</em> (classifying each candidate n-gram as term or non-term) and 58.3–69.6 as a <em>token classifier</em> (labelling each token in a sequence-labelling formulation). A twenty-point gap arises purely from problem formulation. Sequence classification is additionally expensive, since it requires generating and scoring all candidate n-grams.</p><p><strong>Zero-shot cross-lingual transfer is nearly free.</strong> The XLM-R token classifier applied zero-shot to an unseen language achieves 58.3–69.8 F1 — matching, and at the top of the range slightly exceeding, its monolingual counterpart. For low-resource languages this is a consequential result: annotating a new language may be unnecessary.</p><h3>11. The LLM Era: Two Papers, One Decision Rule</h3><p>The 2023 survey identified prompting as an open opportunity, noting that prompt-based methods “remain underexplored for token-level ATE” (§8.5). That gap closed quickly, and the results are more interesting than the usual narrative allows.</p><h3>11.1 LLMs win in the few-shot regime</h3><p>Banerjee et al. (2024) evaluated GPT-3.5-Turbo few-shot in-context learning against a fine-tuned BERT-sized baseline and unsupervised count-based methods. In-context learning outperformed both. The paper also quantified <strong>prompt sensitivity</strong> — measuring performance variation across prompt templates — which remains an unusually honest contribution in a literature that often reports a single template’s results.</p><p>Notably, the authors frame ATE as “a key first step in ontology learning and knowledge graph creation,” a downstream framing shared by Xu et al. (2025) and distinct from the traditional terminology-management perspective.</p><h3>11.2 Fine-tuned encoders still win in-domain</h3><p>Chun et al. (2025), published in Findings of ACL, report the complementary result.</p><figure><img alt="Figure 7. Data: Chun et al. (2025), Findings of ACL 2025, arXiv:2506.21222." src="https://cdn-images-1.medium.com/max/1024/1*IZSYZt7z-O1bXgyLLAgduA.png" /><figcaption><em>Figure 7. Data: Chun et al. (2025), Findings of ACL 2025, arXiv:2506.21222.</em></figcaption></figure><p>Under domain shift on ACTER, the best LLM configuration reaches 60.2 F1 against 61.2 for a task-specific fine-tuned model — statistically a tie. In-domain, the gap widens dramatically: on the BioCreAtIvE gene-mention benchmark, the LLM reaches 53.8 F1 against <strong>88.5</strong> for the fine-tuned encoder.</p><p>These findings are not in conflict. They answer different questions, and together they yield a usable decision rule:</p><blockquote><strong><em>LLMs win when labelled data is scarce. Fine-tuned encoders win when it is not.</em></strong></blockquote><h3>11.3 The counterintuitive part: retrieve by syntax, not semantics</h3><p>Chun et al.’s methodological contribution is a retrieval strategy for selecting few-shot demonstrations. The standard approach retrieves semantically similar examples. The authors argue this fails across domains, since when “cross-domain scenarios lack gold term overlap, retrieving semantically similar sentences may not provide useful guidance.”</p><p>Their alternative retrieves examples with structurally similar <strong>constituency parse trees</strong>, using the FastKASSIM tree kernel. The intuition is that a term’s meaning does not transfer across domains, but its shape does: <em>horizontal axis wind turbine</em> and <em>chronic heart failure treatment</em> share no semantics whatsoever, yet share a syntactic skeleton — and that skeleton is what teaches the model where term boundaries fall.</p><p>To diagnose what the retrieved demonstrations actually contribute, the authors introduce the <strong>Term Overlap Ratio</strong>, measuring the proportion of a query’s gold terms that already appear in its retrieved examples:</p><figure><img alt="Source: Chun, Kim, Kim, Park &amp; Lim (2025), Findings of ACL 2025, arXiv:2506.21222." src="https://cdn-images-1.medium.com/max/1024/1*80VsL3o-WPId7BsjVMfpmA.png" /><figcaption><em>Source: Chun, Kim, Kim, Park &amp; Lim (2025), Findings of ACL 2025, arXiv:2506.21222.</em></figcaption></figure><p>Three results from this paper deserve wider circulation:</p><ol><li><strong>Syntactic retrieval achieves a Term Overlap Ratio of zero cross-domain</strong> — the retrieved demonstrations contain none of the gold terms — and remains competitive regardless. Structural guidance alone suffices.</li><li><strong>Semantic retrieval shows a <em>negative</em> correlation between term overlap and F1 cross-domain.</strong> The authors attribute this to “words functioning as domain-specific terms in one field but generic in others.” The intuitive move of adding semantic similarity measurably hurts.</li><li><strong>An unlexicalized PCFG parser outperforms neural CRF+RoBERTa parsers</strong> for retrieval, supporting the hypothesis that isolating syntax <em>from</em> semantics is what makes the method work.</li></ol><p>The authors are appropriately measured about the overall result, noting that “modest absolute improvements and persistent gaps versus task-specific PLMs underscore inherent limitations of in-context learning for terminology extraction.”</p><h3>12. Choosing a Method</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lD_z73vHQ6bObXkEo-TQgw.png" /><figcaption><em>Table 5. F1 figures from Tran et al. (2023) and Chun et al. (2025); synthesis by the author.</em></figcaption></figure><p><strong>Use unsupervised statistical methods</strong> when no annotated data exists and interpretability matters. Every score decomposes into inspectable components, which matters when a terminologist must justify inclusion decisions.</p><p><strong>Use a Transformer token classifier</strong> when in-domain annotation is available and accuracy is the objective. Frame the task as sequence labelling, not candidate classification.</p><p><strong>Use zero-shot cross-lingual transfer</strong> when the target language lacks annotated data but a related language has it.</p><p><strong>Use LLM in-context learning</strong> in genuine few-shot settings or when entering a domain with no data at all — and select demonstrations by syntactic rather than semantic similarity.</p><h3>12.1 In practice: PyATE</h3><p>For classical algorithms, PyATE implements C-value, Basic, Combo Basic, Weirdness and Term Extractor over spaCy POS tagging:</p><pre>from pyate import combo_basic</pre><pre>print(combo_basic(technical_corpus).sort_values(ascending=False))</pre><pre>import spacy<br>from pyate.term_extraction_pipeline import TermExtractionPipeline</pre><pre>nlp = spacy.load(&quot;en_core_web_sm&quot;)<br>nlp.add_pipe(&quot;combo_basic&quot;)<br>doc = nlp(technical_corpus)<br>print(doc._.combo_basic.sort_values(ascending=False).head(5))</pre><p>Two caveats. PyATE ships a supervised spaCy model, en_acl_terms_sm, reporting 94.71% F-score, 95.41% precision and 94.03% recall — but trained on ACL computer-science terminology, and, per the library&#39;s own documentation, not validated on other domains. Given that cross-domain ACTER results cluster in the 50–70 F1 range, that figure should not be read as general-purpose performance. Second, PyATE targets older spaCy releases; pin your version or use an isolated environment.</p><h3>13. Open Problems</h3><p><strong>Nested terms remain unsolved.</strong> Tran et al. (2023) list them as an open challenge for Transformer models: current systems “often predict nested shorter terms rather than complete multi-word terms,” struggling to infer from context whether a term stands alone or extends further. This is the same phenomenon Frantzi et al. named in 1998 — and it appeared unprompted in our own use case, where <em>axis wind turbine</em> was extracted instead of <em>horizontal axis wind turbine</em>. Twenty-eight years on, the diagnosis has outlived every proposed cure.</p><p><strong>Annotation does not scale.</strong> Building domain-specific annotated corpora is “error-prone, labor-intensive, time-consuming, and subjective.” This is what makes zero-shot and few-shot results consequential rather than merely interesting.</p><p><strong>Ensembling is under-explored.</strong> The 2023 survey recommends combining pretrained language models with the classical statistical measures — termhood, unithood, C-value — rather than treating the paradigms as competitors. Given that C-value contributes an inductive bias about nested structure that no Transformer has yet learned reliably, this seems a promising direction.</p><h3>14. Conclusion</h3><p>Automatic Term Extraction has a twenty-five-year literature, a rigorous multilingual benchmark, and a set of results that are more nuanced than the surrounding discourse on keyword extraction suggests. Its central algorithm predates the deep learning era and remains in half of all published work. Its central open problem — deciding where a term ends — was correctly identified in 1998 and has survived the transition to Transformers and then to large language models intact.</p><p>For practitioners, the practical conclusions are narrow and defensible. If you are extracting the vocabulary of a domain rather than summarising documents, you are doing ATE, and the field’s accumulated results apply. If you have annotated data, fine-tune a token classifier. If you do not, prompt an LLM and select demonstrations by syntax. And if you need to explain to a domain expert why a particular string was included in their termbase, the 1998 algorithm remains the only option that can answer.</p><h3>Reproducing This Work</h3><p>The full pipeline is available as a runnable notebook. There is nothing to install and nothing to download — sections 3 through 8 depend on nothing outside the Python standard library.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ajApMEJcDsbV52wAHPqFaA.png" /></figure><p>Figures 2, 3, 4 and Table 3 are computed directly in the notebook; the values printed in this article are its actual output. Figures 5, 6 and 7 reproduce published results, cited below.</p><h3>References</h3><p>Ahmad, K., Gillam, L., &amp; Tostevin, L. Weirdness indexing for logical document extrapolation and retrieval. <em>Proceedings of TREC-8</em>.</p><p>Banerjee, S., Chakravarthi, B. R., &amp; McCrae, J. P. (2024). Large Language Models for Few-Shot Automatic Term Extraction. <em>Natural Language Processing and Information Systems (NLDB 2024)</em>, Lecture Notes in Computer Science, vol. 14762. Springer, Cham. <a href="https://doi.org/10.1007/978-3-031-70239-6_10">https://doi.org/10.1007/978-3-031-70239-6_10</a></p><p>Blandón Andrade, J. C., Medina Otálvaro, C. M., Zapata Jaramillo, C. M., &amp; Morales Ríos, A. (2026). Approaches, Tools, Algorithms, and Methods for Automatic Term Extraction: A Systematic Literature Mapping. <a href="https://doi.org/10.1177/18758967251392652">https://doi.org/10.1177/18758967251392652</a></p><p>Chun, Y., Kim, M., Kim, D., Park, C., &amp; Lim, H. (2025). Enhancing Automatic Term Extraction with Large Language Models via Syntactic Retrieval. <em>Findings of the Association for Computational Linguistics: ACL 2025</em>. <a href="https://aclanthology.org/2025.findings-acl.516/">https://aclanthology.org/2025.findings-acl.516/</a> · arXiv:2506.21222</p><p>Frantzi, K., Ananiadou, S., &amp; Mima, H. (2000). Automatic recognition of multi-word terms: the C-value/NC-value method. <em>International Journal on Digital Libraries</em>, 3(2), 115–130. <a href="https://doi.org/10.1007/s007999900023">https://doi.org/10.1007/s007999900023</a></p><p>Lu, K. PyATE: Python Automated Term Extraction. <a href="https://github.com/kevinlu1248/pyate">https://github.com/kevinlu1248/pyate</a> · <a href="https://doi.org/10.5281/zenodo.5039290">https://doi.org/10.5281/zenodo.5039290</a></p><p>Rigouts Terryn, A., Hoste, V., &amp; Lefever, E. (2019). In no uncertain terms: a dataset for monolingual and multilingual automatic term extraction from comparable corpora. <em>Language Resources and Evaluation</em>, 54(2), 385–418. <a href="https://doi.org/10.1007/s10579-019-09453-9">https://doi.org/10.1007/s10579-019-09453-9</a></p><p>Rigouts Terryn, A., Hoste, V., Drouin, P., &amp; Lefever, E. (2020). TermEval 2020: Shared Task on Automatic Term Extraction Using the Annotated Corpora for Term Extraction Research (ACTER) Dataset. <em>Proceedings of the 6th International Workshop on Computational Terminology (COMPUTERM 2020)</em>, 85–94. <a href="https://aclanthology.org/2020.computerm-1.12/">https://aclanthology.org/2020.computerm-1.12/</a></p><p>Tran, H. T. H., Martinc, M., Caporusso, J., Doucet, A., &amp; Pollak, S. (2023). The Recent Advances in Automatic Term Extraction: A survey. arXiv:2301.06767. <a href="https://arxiv.org/abs/2301.06767">https://arxiv.org/abs/2301.06767</a></p><p>Tran, H. T. H., Martinc, M., Caporusso, J., Delaunay, J., Doucet, A., &amp; Pollak, S. (2026). Recent Advances in Automatic Term Extraction: A Comprehensive Survey. <em>ACM Computing Surveys</em>. <a href="https://doi.org/10.1145/3787584">https://doi.org/10.1145/3787584</a></p><p>Xu, K., Feng, Y., Li, Q., Dong, Z., &amp; Wei, J. (2025). Survey on terminology extraction from texts. <em>Journal of Big Data</em>. <a href="https://doi.org/10.1186/s40537-025-01077-x">https://doi.org/10.1186/s40537-025-01077-x</a></p><p><strong>Datasets:</strong> ACTER — <a href="https://github.com/AylaRT/ACTER">https://github.com/AylaRT/ACTER</a> (CC BY-NC-SA 4.0)</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f212338829b8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Aho-Corasick vs Regex in Python: Multi-Pattern Benchmark with FlashText and KMP]]></title>
            <link>https://medium.com/@huseyinceniik/aho-corasick-vs-regex-in-python-multi-pattern-benchmark-with-flashtext-and-kmp-d6d8b34f266c?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/d6d8b34f266c</guid>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[python]]></category>
            <category><![CDATA[algorithms]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Sat, 27 Jun 2026 12:02:57 GMT</pubDate>
            <atom:updated>2026-06-27T12:02:57.481Z</atom:updated>
            <content:encoded><![CDATA[<h3>Python Multi-Pattern String Matching: Aho-Corasick, Regex, FlashText, and KMP Benchmarked</h3><p><em>When should you replace regex with Aho-Corasick or FlashText? A rigorous Python benchmark across five string matching methods with complexity analysis, charts, and a practical decision guide</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*N7-MTXlYSTma6slFfDM2wg.png" /><figcaption><strong>Overview of the three main approaches and their performance trade-offs. The 480× speedup refers to a real-world production case reported by Singh (2017): replacing regex with FlashText across 3 million documents and 20 000 keywords reduced processing time from five days to fifteen minutes.</strong></figcaption></figure><p>There is a particular kind of performance bug that only shows up at scale. You write a keyword extraction routine, it works fine on your test set of a few hundred documents, you ship it, and three weeks later someone runs it against the full corpus and it does not finish overnight. The culprit, almost every time, is a regular expression loop that nobody thought to profile and the fix is usually the Aho-Corasick algorithm.</p><p>This article is a systematic benchmark of the main options available in Python for multi-pattern string matching: pyahocorasick (Aho-Corasick), re.compile with alternation, FlashText, KMP, and plain string methods. Each is measured across the dimensions that actually matter in production throughput, keyword-set scalability, memory overhead, and build cost, so you can make an informed choice rather than a habitual one.</p><p><strong>Key findings at a glance:</strong></p><ul><li>Below ~500 keywords, re.compile with alternation is fast enough and the most flexible option.</li><li>Above 500 keywords, Aho-Corasick (pyahocorasick) is 10–100× faster than compiled regex and scales independently of keyword count.</li><li>FlashText matches Aho-Corasick speed but enforces whole-word boundaries-a useful constraint, not a limitation.</li><li>KMP offers no practical advantage over str.find() for multi-pattern use.</li><li>Build time for Aho-Corasick is milliseconds at 10 000 keywords; amortized across a large corpus it is negligible.</li></ul><h3>The Multi-Pattern Matching Problem</h3><p>Given a text corpus of arbitrary size and a dictionary of <em>k</em> patterns, find all occurrences of every pattern in every document. This is the canonical multi-pattern string matching problem, and it appears under many names in practice: keyword extraction, named entity detection, log triage, content moderation, sensitive-word filtering, tokenization preprocessing.</p><p>The naive solution is to iterate over each pattern independently; this carries a time complexity of O(k × n), where <em>n</em> is the total text length. That is acceptable when <em>k</em> is small. When <em>k</em> grows into the thousands, it becomes the reason your pipeline takes five days instead of fifteen minutes.</p><h3>The Contenders</h3><h3>1. Python Built-in String Methods</h3><p>The floor of comparison. Methods like str.find(), in, str.startswith() run at C speed inside CPython but offer no structural advantage for multiple patterns. Each pattern requires a separate traversal of the text.</p><p><strong>Complexity:</strong> O(k × n), same as naive regex<br><strong>Advantage:</strong> zero dependencies, minimal memory<br><strong>Limitation:</strong> no pattern composition, no partial matching</p><h3>2. Regular Expressions: Uncompiled</h3><p>The re.search() / re.findall() calls with a pattern string compiled anew on every invocation. This is the antipattern that inflicts the most damage in real codebases, because the cost of compiling the finite automaton from the pattern string is paid repeatedly.</p><p><strong>Complexity:</strong> O(k × n) per call, plus compilation overhead<br><strong>Advantage:</strong> expressive syntax<br><strong>Limitation:</strong> hidden compilation cost, regex backtracking on pathological patterns</p><h3>3. Regular Expressions: Compiled with Alternation (re.compile)</h3><p>Combining <em>k</em> keywords into a single alternation pattern (word1|word2|...|wordk) and compiling it once is the standard optimization. Under the hood, Python&#39;s re module (which uses a backtracking NFA engine) evaluates this as a single automaton pass over the text, but the automaton itself is not optimal for large alternation sets; pathological inputs can trigger exponential backtracking.</p><p><strong>Complexity:</strong> O(n) average, potentially worse<br><strong>Advantage:</strong> single compilation, flexible pattern syntax<br><strong>Limitation:</strong> backtracking risk, memory usage grows with pattern count, slow to compile for large <em>k</em></p><h3>4. Knuth-Morris-Pratt (KMP)</h3><p>KMP constructs a deterministic finite automaton from a <em>single</em> pattern and achieves O(n + m) matching where <em>m</em> is the pattern length. It is the theoretical predecessor of Aho-Corasick but is not designed for multi-pattern use: running KMP independently for each of <em>k</em> patterns gives back O(k × n) total complexity.</p><p><strong>Complexity:</strong> O(n + m) per pattern, O(k × (n + m)) total<br><strong>Advantage:</strong> no backtracking, clean theoretical foundation<br><strong>Limitation:</strong> not natively multi-pattern</p><h3>5. Aho-Corasick</h3><p>The algorithm by Alfred Aho and Margaret Corasick (1975) generalizes KMP to multiple patterns by building a trie over all patterns and augmenting it with <em>failure links</em>, which are pointers that redirect the automaton to the longest proper suffix of the current state that is also a prefix of some pattern. The result is an automaton that processes the text in a single left-to-right pass, regardless of how many patterns are loaded.</p><p><strong>Complexity:</strong> O(Σ|p_i| + n + z), where z is the number of matches<br><strong>Advantage:</strong> linear in text length, keyword-count-independent search time<br><strong>Limitation:</strong> memory proportional to total pattern length × alphabet size (when transitions are precomputed); build time proportional to total pattern length</p><h3>6. FlashText</h3><p>FlashText (Singh, 2017) is a Python library that implements the same Trie/Aho-Corasick principle but restricts matching to whole-word boundaries. It is optimized for the extract-and-replace use case and exposes a simpler API than pyahocorasick. The word-boundary restriction eliminates false partial matches (it will not match &quot;apple&quot; inside &quot;pineapple&quot;) but also means it cannot be used for substring matching.</p><p><strong>Complexity:</strong> O(n) search, O(Σ|p_i|) build<br><strong>Advantage:</strong> simple API, built-in replace, whole-word semantics<br><strong>Limitation:</strong> no substring matching, no regex-style patterns</p><h3>Benchmark Setup</h3><p>All benchmarks were conducted on synthetic data to isolate algorithmic behavior from corpus-specific effects. The experimental variables are:</p><ul><li><strong>Number of keywords (k):</strong> 10, 50, 100, 500, 1 000, 5 000, 10 000</li><li><strong>Text length (n):</strong> 10 000, 100 000, 1 000 000 characters</li><li><strong>Measurement:</strong> wall-clock time via timeit (5 repetitions, best of 3 runs), peak RSS memory via tracemalloc</li></ul><p>Keywords were sampled from a fixed vocabulary of common English words; the text corpus was generated by concatenating random sentences from the same vocabulary, ensuring that approximately 15% of patterns appear in any given text sample. Pattern lengths range from 3 to 12 characters.</p><h3>Algorithm Comparison Summary</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LFbVnkG5ue9IXD6QW4LtMA.png" /></figure><h3>Benchmark Results: Speed, Memory, and Build Time</h3><h3>Search Time vs. Keyword Count</h3><p>The most consequential dimension is how each method scales with the number of patterns. Figure 1 plots search time on a log-log scale across seven keyword-count values from 10 to 10 000, with text length held constant at 100 000 characters.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7RMZHrkonfQvKHD6a0n1gA.png" /></figure><p>The separation is stark. str.find() loops, uncompiled regex, and per-pattern KMP scale linearly with <em>k</em>: they all roughly double in cost when the keyword count doubles. At <em>k</em> = 10 000, the gap between these methods and Aho-Corasick spans roughly two to three orders of magnitude.</p><p>Compiled regex with alternation occupies a more nuanced position. Because Python’s re module converts the alternation to a single NFA, search time does not scale linearly with <em>k</em>, yet it is not constant either. Large alternation patterns push the NFA into increasingly deep state graphs, and worst-case backtracking behaviour on adversarial inputs can degrade performance unpredictably. On benign English text, compiled regex holds up to around 500–1 000 keywords before Aho-Corasick pulls decisively ahead.</p><p>Aho-Corasick and FlashText exhibit essentially flat search time as <em>k</em> grows. This is the theoretical guarantee playing out in practice: the text is traversed once, and the automaton resolves each character transition in O(1). The difference between the two at large <em>k</em> is primarily build-time and memory overhead, not search time.</p><h3>Search Time vs. Text Length</h3><p>With keyword count held constant at 1 000, Figure 2 shows how the three fastest methods scale with text length.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZNfVCad1ox1PZ4fFD8NReQ.png" /></figure><p>All three grow linearly with text length, as their O(n) complexities predict. The important observation here is the constant factor: Aho-Corasick and FlashText consistently process the same character volume in less time than compiled regex, with the gap widening slightly at larger <em>n</em>. This is attributable to the overhead of Python’s backtracking NFA matching loop versus the deterministic transition table lookups of the Aho-Corasick automaton.</p><h3>Build Time</h3><p>Build time is the deferred cost of Aho-Corasick’s O(1)-per-character search.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tXWOsZO8P4xGDM_sHVHJSA.png" /></figure><p>Constructing the automaton takes time proportional to the total length of all patterns, and with precomputed transitions it allocates memory proportional to (states × alphabet size). For <em>k</em> = 10 000 short keywords, this means tens of milliseconds and a few megabytes, which is negligible if the automaton is built once and reused across a large corpus, but relevant in latency-sensitive settings where patterns change frequently.</p><p>re.compile becomes notably slow at large <em>k</em> because alternation compilation is not linear in the total pattern length; the regex engine must analyse all possible paths through the NFA. At <em>k</em> = 10 000 keywords, re.compile takes considerably longer than building an equivalent Aho-Corasick automaton.</p><h3>Memory</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1lis2i_1nysd7gQaSGKSWw.png" /></figure><p>Aho-Corasick with precomputed transitions carries the highest memory footprint, driven by storing all state-symbol transitions explicitly. FlashText is more memory-efficient because it builds a Trie without precomputing the full transition table. Compiled regex memory scales roughly linearly with total pattern length.</p><p>For most real workloads where the pattern dictionary fits comfortably in RAM, the memory difference is not a practical concern. It becomes relevant only when the vocabulary grows into the hundreds of thousands of entries or when operating in constrained environments.</p><h3>Speedup Over Compiled Regex</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hk3RsH3AKY2AQIqmHq4-_g.png" /></figure><p>At <em>k</em> = 1 000 keywords on a 100 000-character document, Aho-Corasick and FlashText both deliver search speedups well above 1× over compiled regex, and this ratio widens as <em>k</em> increases. The crossover point (below which compiled regex remains competitive) sits around <em>k</em> = 200–500 in typical conditions, which aligns with the empirical recommendation of 500 keywords cited across multiple practitioner accounts.</p><h3>Heatmaps: Full k × n Grid</h3><p>The following three heatmaps present search time (in milliseconds) across the full combination of keyword counts and text lengths tested. Darker cells indicate longer execution times.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ek5pRAUGM6vSrIVsLr3I-A.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gTznEwvuD1cudwN9Nde2GA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9g_G6tV9EiHmPG3NNXdA_w.png" /></figure><p>The heatmaps make the algorithmic difference concrete. The regex heatmap darkens sharply as both <em>k</em> and <em>n</em> increase, reflecting the O(k × n) regime. The Aho-Corasick and FlashText heatmaps darken only along the text-length axis; keyword count has no visible effect on colour, because search time is indeed independent of <em>k</em> once the automaton is built.</p><h3>Numeric Results</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*utfhHa57bYLQbtU7FG5exg.png" /></figure><h3>A Note on Pattern Expressiveness</h3><p>These benchmarks measure what Aho-Corasick and FlashText are <em>fast at</em>: exact multi-keyword matching. They cannot express the class of patterns that regex handles naturally: character classes, quantifiers, anchors, and lookahead. This is not a shortcoming to be engineered around; it is a deliberate trade-off. A finite automaton that commits to exact pattern strings during the build phase can make strong guarantees about search complexity. A backtracking NFA that interprets patterns at runtime cannot.</p><p>The practical implication is that these tools are not alternatives to regex in general; they are alternatives to regex for the specific sub-problem of exact keyword lookup. When the problem is “does this document contain any of these 5 000 product names”, Aho-Corasick is the right tool. When the problem is “extract all strings matching \d{4}-\d{2}-\d{2}&quot;, it is not.</p><h3>Decision Guide: Which Python String Matching Method to Use</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6zye8876a_Dn2-dbSwbwFw.png" /></figure><p><strong>Use string methods</strong> (str.find, in, str.startswith) when matching a handful of known prefixes or exact strings and the keyword set is static and small. These are the fastest option for <em>k</em> ≤ 10 because there is no overhead at all.</p><p><strong>Use compiled regex</strong> (re.compile) when patterns require regular-expression syntax (character classes, quantifiers, anchors) or when the keyword count is below roughly 500 and you need the flexibility of partial-word matching. Pre-compile once and cache the object.</p><p><strong>Use Aho-Corasick</strong> (pyahocorasick) when operating on exact string patterns at <em>k</em> ≥ 500, particularly when the automaton can be built once and reused across a large corpus, or when substring matching is required.</p><p><strong>Use FlashText</strong> when the use case is whole-word extraction or replacement (entity normalization, synonym standardization, keyword tagging) and partial matches are undesirable. Its enforced word-boundary semantics eliminate an entire class of false-positive bugs.</p><p><strong>Use KMP</strong> when matching a single pattern against many texts and you cannot introduce a dependency on pyahocorasick. For multi-pattern use it offers no advantage over naive looping.</p><h3>Practical Recommendations</h3><p><em>Build once, search many times.</em> The amortization argument for Aho-Corasick holds only if the automaton is not rebuilt for every document. In a streaming pipeline, build the automaton at startup and hold it for the lifetime of the process. The build cost is a one-time investment; the search cost is paid per document.</p><p><em>The hybrid pattern is underused.</em> For workloads that mix large keyword dictionaries with a small number of complex regex patterns, the most effective approach is to run Aho-Corasick first to identify candidate sentences or paragraphs, then apply regex only to those candidates. This reduces the regex surface area by orders of magnitude while preserving full pattern expressiveness where needed.</p><p><em>Profiling before optimizing.</em> The performance difference between compiled regex and Aho-Corasick matters only when string matching is actually the bottleneck. For short documents and moderate keyword counts, I/O latency, tokenization, and model inference time typically dwarf the search cost. Profile first.</p><h3>Python Code Examples</h3><p>Install the two external libraries if you have not already:</p><pre>pip install pyahocorasick flashtext</pre><h3>str.find() loop</h3><pre>keywords = [&quot;error&quot;, &quot;timeout&quot;, &quot;failure&quot;]<br>text = &quot;connection timeout after retry&quot;</pre><pre>for kw in keywords:<br>    idx = text.find(kw)<br>    if idx != -1:<br>        print(f&quot;Found &#39;{kw}&#39; at {idx}&quot;)</pre><h3>re.compile with alternation</h3><pre>import re</pre><pre>keywords = [&quot;error&quot;, &quot;timeout&quot;, &quot;failure&quot;]<br>pattern = re.compile(&quot;|&quot;.join(re.escape(k) for k in keywords))</pre><pre>for m in pattern.finditer(text):<br>    print(f&quot;Found &#39;{m.group()}&#39; at {m.start()}&quot;)</pre><h3>Aho-Corasick (pyahocorasick)</h3><pre>import ahocorasick</pre><pre>A = ahocorasick.Automaton()<br>for idx, word in enumerate(keywords):<br>    A.add_word(word, (idx, word))<br>A.make_automaton()</pre><pre>for end_idx, (_, word) in A.iter(text):<br>    start = end_idx - len(word) + 1<br>    print(f&quot;Found &#39;{word}&#39; at {start}&quot;)</pre><h3>FlashText</h3><pre>from flashtext import KeywordProcessor</pre><pre>kp = KeywordProcessor()<br>kp.add_keywords_from_list(keywords)</pre><pre>found = kp.extract_keywords(text)<br>print(found)  # [&#39;timeout&#39;]</pre><pre>replaced = kp.replace_keywords(text)</pre><h3>KMP (single pattern, no dependencies)</h3><pre>def kmp_search(text: str, pattern: str) -&gt; list[int]:<br>    n, m = len(text), len(pattern)<br>    fail = [0] * m<br>    j = 0<br>    for i in range(1, m):<br>        while j &gt; 0 and pattern[i] != pattern[j]:<br>            j = fail[j - 1]<br>        if pattern[i] == pattern[j]:<br>            j += 1<br>        fail[i] = j<br>    results, j = [], 0<br>    for i in range(n):<br>        while j &gt; 0 and text[i] != pattern[j]:<br>            j = fail[j - 1]<br>        if text[i] == pattern[j]:<br>            j += 1<br>        if j == m:<br>            results.append(i - m + 1)<br>            j = fail[j - 1]<br>    return results</pre><pre>print(kmp_search(&quot;connection timeout after retry&quot;, &quot;timeout&quot;))  # [11]</pre><h3>Conclusion</h3><p>The Aho-Corasick algorithm has been in continuous production use for fifty years: network intrusion detection systems, genomic sequence search engines, compiler lexers, and most recently the tokenization stages of large language model pipelines. Its longevity is earned. For the specific and common problem of matching many exact patterns against large text, it is the theoretically correct and practically fastest option available, and the Python ecosystem makes it accessible with minimal overhead via pyahocorasick.</p><p>None of this makes regular expressions obsolete. The two tools answer different questions. Knowing which question you are actually asking is, in the end, the only benchmark that matters.</p><h3>References</h3><ul><li>Aho, A. V., &amp; Corasick, M. J. (1975). Efficient string matching: An aid to bibliographic search. <em>Communications of the ACM</em>, 18(6), 333–340.</li><li>Knuth, D. E., Morris, J. H., &amp; Pratt, V. R. (1977). Fast pattern matching in strings. <em>SIAM Journal on Computing</em>, 6(2), 323–350.</li><li>Singh, V. (2017). Replace or retrieve keywords in documents at scale. <em>arXiv preprint arXiv:1711.00046</em>.</li><li>Python Software Foundation. (2024). re-Regular expression operations. Python 3 documentation.</li><li>pyahocorasick: <a href="https://github.com/WojciechMula/pyahocorasick">https://github.com/WojciechMula/pyahocorasick</a></li><li>flashtext: <a href="https://github.com/vi3k6i5/flashtext">https://github.com/vi3k6i5/flashtext</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d6d8b34f266c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Multi-Agent RAG System with a Self-Improving Eval Loop]]></title>
            <link>https://medium.com/@huseyinceniik/building-a-multi-agent-rag-system-with-a-self-improving-eval-loop-a13fab6ebe8c?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/a13fab6ebe8c</guid>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[generative-ai]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Sat, 09 May 2026 19:07:17 GMT</pubDate>
            <atom:updated>2026-05-09T19:07:17.206Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a Self-Improving Multi-Agent RAG System: Architecture, Evaluation, and the Human-in-the-Loop Prompt Feedback Cycle</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uoX5MFw-rCOVJu_q3NX0Vg.png" /><figcaption><em>A deep-dive into a production-grade LLM orchestration system with streaming observability, adversarial evaluation, and regression-blocked prompt versioning.</em></figcaption></figure><h3>Abstract</h3><p>Retrieval-Augmented Generation (RAG) has become the dominant paradigm for grounding large language model (LLM) responses in verifiable external knowledge. Yet single-agent RAG pipelines expose a fundamental tension: they conflate retrieval quality, reasoning depth, and answer synthesis into a single, opaque forward pass that is difficult to evaluate, audit, or improve systematically. The system incorporates a self-improving evaluation loop that automatically identifies underperforming prompt dimensions, proposes targeted rewrites, and gates their promotion behind a human approval step with quantitative regression detection. All agent activity is streamed to clients via Server-Sent Events (SSE) and is fully persisted for reproducible auditing. The full source code is publicly available at <a href="https://github.com/huseyincenik/multi_agent_rag_system"><strong>https://github.com/huseyincenik/multi_agent_rag_system</strong></a>.</p><h3>1. Introduction</h3><p>The progression from prototype to production in LLM-based systems is rarely linear. A system that achieves reasonable accuracy on held-out benchmarks frequently degrades in the presence of adversarial inputs, underspecified queries, or distribution shift in user behaviour. Classical software engineering addresses quality regression through unit testing and CI/CD gates; LLM systems require an analogue that operates at the level of <em>prompt behaviour</em> rather than function signatures.</p><p>The design described here addresses three interrelated problems:</p><ol><li><strong>Compositional reasoning.</strong> Complex queries benefit from decomposition into dependent sub-tasks, multi-hop retrieval, and explicit contradiction resolution — capabilities that are difficult to reliably embed in a single monolithic prompt.</li><li><strong>Systematic evaluation.</strong> Evaluation must be multi-dimensional, reproducible, and capable of isolating which agent or prompt dimension is responsible for failure — not merely reporting an aggregate accuracy figure.</li><li><strong>Controlled improvement.</strong> Prompt changes should be governed by the same discipline as code changes: proposed in a diff-able format, reviewed by a human, and blocked from promotion if they regress any previously passing dimension.</li></ol><h3>2. System Architecture</h3><h3>2.1 Overview</h3><p>The system is composed of five Docker services orchestrated via Docker Compose: a FastAPI application server, an ARQ background worker, PostgreSQL 16 with the pgvector extension, Redis, and an Adminer log browser.</p><pre>Client (HTTP)<br>    │  POST /query/stream<br>    ▼<br>FastAPI (port 9000)<br>    │  enqueue ARQ job<br>    ▼<br>ARQ Worker (async)<br>    │<br>    ├── Orchestrator  →  routing plan (LLM-generated)<br>    │       │<br>    │       ├── Decomposition Agent   (sub-task DAG)<br>    │       ├── RAG Agent             (multi-hop pgvector)<br>    │       ├── Critique Agent        (per-claim confidence scoring)<br>    │       └── Synthesis Agent       (provenance-mapped final answer)<br>    │<br>    ├── Redis Streams  →  SSE (XADD / XREAD)<br>    └── PostgreSQL     →  jobs, eval_runs, agent_logs, prompt_versions</pre><p>All inter-agent communication passes through a SharedContext object with a defined JSON schema. Agents do not call each other directly; the orchestrator mediates every handoff. This constraint eliminates implicit coupling between agents and makes the routing plan fully auditable.</p><h3>2.2 Framework Stack</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GigtXrLBxEang3oNDkEFlw.png" /></figure><p><strong>FastAPI</strong> was chosen for its native async support, automatic OpenAPI documentation generation, and dependency injection system — the latter being critical for clean database session lifecycle management across concurrent requests. <strong>ARQ</strong> provides a pure-asyncio Redis-backed job queue that integrates naturally with an async codebase without requiring a separate process manager such as Celery.</p><p><strong>pgvector</strong> enables semantic retrieval directly within PostgreSQL using HNSW indexes on 1536-dimensional embeddings (text-embedding-3-small), eliminating the need for a separate vector database service. <strong>Redis Streams</strong> (XADD/XREAD) provide durable, ordered event delivery for SSE — superior to in-process queues because events survive worker restarts and can be replayed.</p><h3>3. Agent Design</h3><h3>3.1 Orchestrator</h3><p>The orchestrator receives the raw query and the global token budget. It issues a single LLM call to produce a <strong>routing plan</strong> — a structured JSON document specifying:</p><ul><li>which agents to invoke and in what order,</li><li>the token budget allocated to each agent,</li><li>dependency relationships (expressed as a DAG) between agents,</li><li>an error strategy per agent (retry, skip, or abort).</li></ul><p>The orchestrator does not itself answer the query. Its sole function is planning. This separation of concerns means that routing logic can be evaluated and improved independently of answer quality.</p><h3>3.2 RAG Agent</h3><p>The RAG agent performs <strong>at least two retrieval hops</strong> against a pgvector-indexed corpus of document chunks. Hop 1 issues a cosine similarity search against the original query embedding. Hop 2 synthesises a follow-up query from the first hop’s results and issues a second search. Only chunks that contribute to the final answer are cited, and each citation records the source chunk ID and the sentence it supports.</p><p>A PolicyViolation is logged if fewer than two hops complete, making single-hop shortcuts visible in the execution trace. This constraint prevents the retrieval agent from taking the degenerate path of returning the top-1 result without verifying coverage.</p><p>The embedding model is text-embedding-3-small; the generation model is gpt-4o-mini at temperature=0 for deterministic outputs during evaluation.</p><h3>3.3 Decomposition Agent</h3><p>For ambiguous or compound queries, the decomposition agent asks the LLM to produce a <strong>typed sub-task DAG</strong>: a set of named sub-tasks with typed outputs and explicit dependency edges. The DAG is validated for acyclicity using a topological sort before execution begins. Independent sub-tasks are executed in parallel topological waves; dependent sub-tasks wait for their predecessors to resolve.</p><h3>3.4 Critique Agent</h3><p>The critique agent reviews every prior agent’s output stored in SharedContext, producing per-claim confidence scores (0–1) and flagging specific text spans it disagrees with. A flag must reference a non-empty character span (start &lt; end) within the text; whole-output rejections without a span reference are rejected by the Pydantic validator. This design forces the critique agent to be precise and falsifiable.</p><h3>3.5 Synthesis Agent</h3><p>The synthesis agent merges all outputs, applies <strong>contradiction resolution</strong> using critique flags, and produces a final answer with a <strong>provenance map</strong> linking each output sentence to its source agent and (where applicable) source document chunk. Four resolution strategies are supported:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UhofGFz2wgJ01A2O4mgWmQ.png" /></figure><p>In SSE mode, the synthesis agent emits agent_token events per OpenAI streaming fragment, giving the client per-token visibility into which agent is writing at any moment.</p><h3>4. Context Window Management</h3><p>A ContextBudgetManager tracks cumulative token consumption per agent per turn using tiktoken&#39;s CL100K tokenizer. Before any agent appends to its context, it queries the remaining budget. If the assembled context exceeds the declared budget, a <strong>compression agent</strong> is invoked to summarise older context, with the constraint that structured data (tool outputs, scores, citations) is preserved losslessly while conversational filler is compressed.</p><p>Agents that overflow without requesting compression are logged as PolicyViolation events with the excess token count. This makes budget compliance measurable and surfaceable in the evaluation harness.</p><h3>5. Tool Calling</h3><p>Four tools are registered with the orchestrator, each with a <strong>defined failure contract</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0N4_qHqLm-l9MHngldtIBw.png" /></figure><p>All tool calls are logged with input hash, output hash, latency, and the agent’s binary accepted/rejected decision on the result. An agent that determines a tool result is insufficient may re-call the tool with a modified input, up to two retries, each logged as a separate tool_retry event.</p><h3>6. Evaluation Pipeline</h3><h3>6.1 Test Suite</h3><p>The evaluation harness runs 15 test cases through the full pipeline. The suite is partitioned into three categories:</p><ul><li><strong>Baseline (5 cases):</strong> Straightforward queries with known correct answers; primarily test retrieval precision and citation accuracy.</li><li><strong>Ambiguous (5 cases):</strong> Underspecified or multi-part queries; primarily test decomposition quality and synthesis coherence.</li><li><strong>Adversarial (5 cases):</strong> Prompt injection attempts, queries with confident but factually wrong premises, and queries designed to cause the critique agent to disagree with the synthesis agent.</li></ul><h3>6.2 Scoring Dimensions</h3><p>Each test case is scored independently across eight dimensions:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1ElSLZMGE8evHNmQB3NNGg.png" /></figure><p>Every dimension produces a numeric score ∈ [0, 1] and a written justification string. No third-party evaluation framework is used; all scoring logic is implemented in eval_harness.py.</p><h3>6.3 Stored Results from the Reference Run</h3><p>A reference run (eval_run_id: de1dd5ab) on the system following a clean no-cache Docker rebuild produced the following results:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cbNjPZR14pgi_AzD2l8tTg.png" /></figure><p>The wrong_premise_handling score of 0.000 reflects a known limitation: the synthesis agent, when given a query containing a confident but factually incorrect premise, does not reliably challenge the premise before forming an answer. This is the dimension targeted by the self-improving loop in the reference run.</p><h3>7. The Self-Improving Prompt Loop</h3><p>The self-improving loop operates in five sequential stages, each persisted to the database with timestamps:</p><pre>POST /eval/run<br>    │<br>    ▼<br>failure_analyzer identifies worst (dimension, agent_id)<br>    │<br>    ▼<br>prompt_rewrite_proposer generates structured diff + justification<br>    │<br>    ▼  stored as prompt_rewrite (status: pending)<br>    │<br>POST /eval/prompt-rewrite/{id}/approve<br>    │<br>    ├── regression check:<br>    │     for each dimension where baseline ≥ 0.70<br>    │     if candidate_score &lt; baseline_score - 0.05 → 409 REGRESSION_DETECTED<br>    │<br>    ├── force: true → override regression block<br>    │<br>    └── approved → status: approved<br>    │<br>POST /eval/retry-failed?rewrite_id={id}<br>    │<br>    ▼<br>targeted_eval re-runs only previously failed cases<br>    │<br>    ▼<br>delta_scores JSONB stored in rewrite_approvals</pre><h3>7.1 Regression Detection</h3><p>The regression gate selects the <strong>most recent eval run older than the candidate run</strong> as the baseline. This ensures that the baseline always predates the candidate, preventing a newer run from being incorrectly used as the reference point. The SQL sub-select is:</p><pre>SELECT id FROM eval_runs<br>WHERE id != CAST(:id AS UUID)<br>  AND triggered_at &lt; (<br>      SELECT triggered_at FROM eval_runs WHERE id = CAST(:id AS UUID)<br>  )<br>ORDER BY triggered_at DESC LIMIT 1</pre><p>A regression is declared when a dimension that previously scored ≥ 0.70 drops by ≥ 0.05. The HTTP response to a regression-blocked approval is:</p><pre>{<br>  &quot;error_code&quot;: &quot;REGRESSION_DETECTED&quot;,<br>  &quot;regressions&quot;: [<br>    {<br>      &quot;dimension&quot;: &quot;answer_correctness&quot;,<br>      &quot;baseline_score&quot;: 0.8,<br>      &quot;candidate_score&quot;: 0.71,<br>      &quot;delta&quot;: -0.09,<br>      &quot;baseline_run_id&quot;: &quot;...&quot;<br>    }<br>  ]<br>}</pre><p>Callers can override the block by setting force: true in the request body, which is logged as an explicit human decision rather than a system bypass.</p><h3>8. End-to-End Use Case Walkthrough: “What is machine learning?”</h3><p>The following walkthrough traces a single query through every layer of the system — from the initial SSE stream to eval scoring, prompt rewrite approval, and targeted re-evaluation. All output was captured from a live Docker environment on 2026–05–07 (gpt-4o-mini, temperature=0).</p><h3>Step 1 — POST /query/stream</h3><pre>POST /query/stream<br>Content-Type: application/json</pre><pre>{ &quot;query&quot;: &quot;What is machine learning?&quot;, &quot;context_budget&quot;: 3000 }</pre><p>The response is a persistent SSE connection. 283 events were received before the stream closed:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kONn4ibVb-f2vTDi-P2y6Q.png" /></figure><p>The orchestrator decided to skip the critique agent for this query (low ambiguity, no retrieved contradictions) and execute the three-agent chain in sequence.</p><h3>Step 2 — GET /trace/{job_id}</h3><p>The execution trace reconstructed from structured logs confirms the routing plan and records a completed_at timestamp. The policy_violations_total: 0 field confirms no agent exceeded its declared token budget.</p><h3>Step 3 — GET /logs</h3><p>Nine log entries were persisted for this job across three event_type values: budget_declared, budget_consumed, and routing. Each row carries agent_id, input_hash, output_hash, latency_ms, and token_count.</p><h3>Step 4 — POST /eval/run</h3><p>The full 15-case evaluation suite was executed as an ARQ background job. After scoring, the failure analyzer identified the worst-performing (dimension, agent) pair:</p><pre>{<br>  &quot;eval_run_id&quot;: &quot;8828d321-847e-4be0-be1c-288e7de4c46d&quot;,<br>  &quot;total_cases&quot;: 15,<br>  &quot;failure_analysis&quot;: {<br>    &quot;worst_dimension&quot;: &quot;wrong_premise_handling&quot;,<br>    &quot;worst_agent_id&quot;: &quot;synthesis&quot;<br>  }<br>}</pre><h3>Step 5 — GET /eval/summary</h3><p>The summary endpoint returns scores broken down by both category and dimension:</p><pre>overall_score      : 0.8083<br>worst_dimension    : wrong_premise_handling (0.0000)<br>categories         : baseline, ambiguous, adversarial</pre><p>The wrong_premise_handling score of 0.0 indicates that the synthesis agent accepted factually incorrect premises in all adversarial-premise test cases rather than challenging them — the exact failure mode the self-improving loop is designed to target.</p><h3>Step 6 — POST /prompt-rewrite/{id}/decision</h3><p>A rewrite for the synthesis agent&#39;s system prompt was auto-proposed by prompt_rewrite_proposer. A human reviewer approved it via the approval endpoint. Because this was the first eval run (no prior baseline existed), the regression gate had no prior scores to compare against and passed unconditionally:</p><pre>{ &quot;decision&quot;: &quot;approved&quot;, &quot;decided_at&quot;: &quot;2026-05-07T12:24:33.646529+00:00&quot; }</pre><h3>Step 7 — POST /eval/retry-failed</h3><p>The targeted re-evaluation ran only the 6 test cases that failed in the previous run (all wrong_premise_handling cases plus the baseline cases that scored below 0.70):</p><pre>{<br>  &quot;new_eval_run_id&quot;: &quot;eeed8187-af3f-49ba-b389-834897e9f6f1&quot;,<br>  &quot;cases_targeted&quot;: 6<br>}</pre><p>The per-case delta_scores JSONB payload (stored in rewrite_approvals) records the before/after score for every dimension, enabling a diff-able comparison between the original run and the post-rewrite run.</p><h3>Use Case Summary</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YgG9UdGf1-kPMiSTwF7Wkw.png" /></figure><p>All 7 steps passed on the first clean no-cache Docker build.</p><h3>9. Streaming and Observability</h3><p>All agent activity is streamed to the client via <strong>Server-Sent Events</strong> over Redis Streams (XADD / XREAD). The SSE event schema:</p><pre>event: agent_token<br>data: {&quot;agent_id&quot;: &quot;synthesis&quot;, &quot;token&quot;: &quot; based&quot;, &quot;budget_remaining&quot;: 1847}</pre><pre>event: tool_call<br>data: {&quot;agent_id&quot;: &quot;rag&quot;, &quot;tool&quot;: &quot;vector_search&quot;, &quot;latency_ms&quot;: 43}</pre><pre>event: job_complete<br>data: {&quot;job_id&quot;: &quot;...&quot;, &quot;total_tokens&quot;: 2341, &quot;duration_ms&quot;: 4812}</pre><p>Because events are written to Redis Streams before being forwarded to the SSE connection, they survive worker restarts and are available for replay. A client that connects after a job completes can reconstruct the full event sequence by reading from the beginning of the stream.</p><p>Structured logs follow a consistent schema across all agents: timestamp, agent_id, event_type, input_hash, output_hash, latency_ms, token_count, and policy_violation (nullable). The GET /trace/{job_id} endpoint reconstructs the full execution sequence in chronological order, including routing plan, per-agent events, tool calls, and handoffs.</p><h3>10. Discussion</h3><h3>9.1 Architectural Trade-offs</h3><p>The choice to implement multi-hop retrieval within a single agent (rather than as a separate retrieval orchestration layer) reflects a pragmatic trade-off: a dedicated retrieval planner would add a round-trip LLM call per query in exchange for more modular control over retrieval strategy. For the scale of this system (single-tenant, non-latency-critical evaluation), the simpler design is appropriate.</p><p>The use of Redis Streams rather than in-process asyncio queues for SSE delivery adds operational complexity (Redis becomes a critical dependency) but provides durability and the ability to serve multiple concurrent SSE consumers for the same job — a requirement for production deployments where browser reconnects must not lose events.</p><h3>9.2 Known Limitations</h3><ul><li><strong>Wrong premise handling</strong> is the weakest dimension. The synthesis agent does not reliably challenge a confident but false premise before generating an answer, because the critique agent must first flag it — and the critique agent only reviews other agents’ outputs, not the input query itself.</li><li><strong>Citation accuracy</strong> (0.667) reflects that the RAG agent sometimes cites chunks that are topically relevant but do not directly support the claimed sentence. A re-ranking step before citation selection would likely improve this.</li><li><strong>The evaluation suite is static and small</strong> (15 cases). It validates the pipeline’s behaviour on known inputs but does not cover the full distribution of real user queries.</li><li><strong>No authentication or rate limiting</strong> is implemented. All endpoints are publicly accessible.</li></ul><h3>9.3 Directions for Future Work</h3><p>The most impactful extension would be closing the feedback loop between production traffic and the evaluation suite: logging real user queries that receive low confidence scores and automatically promoting them as new test cases. This would make the eval suite a living document rather than a static fixture.</p><p>A second high-value extension is <strong>cross-dimension regression analysis</strong>: the current regression gate operates per-dimension in isolation. A rewrite that improves wrong_premise_handling while degrading answer_correctness by exactly 0.04 (below the 0.05 threshold) passes the gate despite making the system worse overall. A joint regression score across dimensions would address this.</p><h3>11. Conclusion</h3><p>This article has described a multi-agent RAG system in which retrieval, decomposition, critique, and synthesis are handled by specialised agents coordinated by a runtime-planning orchestrator. The system implements a closed self-improving loop: an evaluation harness identifies underperforming prompt dimensions, a meta-agent proposes targeted rewrites, and a regression-blocking approval gate ensures that promoted changes do not degrade passing dimensions. All activity is streamed in real time via Server-Sent Events and persisted in a fully queryable audit trail.</p><p>The design choices — ARQ over Celery, pgvector over a dedicated vector database, Redis Streams over in-process queues, and custom scoring logic over third-party eval frameworks — reflect a preference for minimising service count while retaining the operational properties (durability, observability, reproducibility) that distinguish production systems from prototypes.</p><p>The complete implementation, including Docker Compose configuration, database schema, all agent source code, and the 15-case evaluation suite, is available at:</p><p><a href="https://github.com/huseyincenik/multi_agent_rag_system"><strong>https://github.com/huseyincenik/multi_agent_rag_system</strong></a></p><h3>References</h3><ol><li>Lewis, P., et al. (2020). <em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks</em>. NeurIPS 2020.</li><li>Yao, S., et al. (2023). <em>ReAct: Synergizing Reasoning and Acting in Language Models</em>. ICLR 2023.</li><li>Madaan, A., et al. (2023). <em>Self-Refine: Iterative Refinement with Self-Feedback</em>. NeurIPS 2023.</li><li>Chase, H. (2022). <em>LangChain: Building Applications with LLMs through Composability</em>. GitHub.</li><li>OpenAI. (2023). <em>Function Calling and Other API Updates</em>. OpenAI Blog.</li><li>Johnson, J., Douze, M., &amp; Jégou, H. (2019). <em>Billion-scale similarity search with GPUs</em>. IEEE Transactions on Big Data.</li><li>FastAPI Documentation. Sebastián Ramírez. <a href="https://fastapi.tiangolo.com/">https://fastapi.tiangolo.com</a></li><li>ARQ Documentation. Samuel Colvin. <a href="https://arq-docs.helpmanual.io/">https://arq-docs.helpmanual.io</a></li><li>pgvector: Open-source vector similarity search for PostgreSQL. <a href="https://github.com/pgvector/pgvector">https://github.com/pgvector/pgvector</a></li></ol><p><em>The author used GitHub Copilot (Claude Sonnet 4.6) as an AI pair-programming assistant throughout the implementation. All architectural decisions, test case authorship, and code review were performed by the author.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a13fab6ebe8c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Complete Visual Guide to Markdown & HTML Formatting in Data Science Notebooks and READMEs]]></title>
            <link>https://medium.com/@huseyinceniik/the-complete-visual-guide-to-markdown-html-formatting-in-data-science-notebooks-and-readmes-1bdc98bfa4b7?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/1bdc98bfa4b7</guid>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[github]]></category>
            <category><![CDATA[html]]></category>
            <category><![CDATA[jupyter-notebook]]></category>
            <category><![CDATA[markdown]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Sat, 25 Apr 2026 13:30:08 GMT</pubDate>
            <atom:updated>2026-04-25T13:45:32.277Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Extracted from 500+ Kaggle competition notebooks — every pattern that actually works</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zpE4voSylij9CaCNwlnXEg.png" /></figure><p>Every top-performing Kaggle notebook does something that most beginner notebooks don’t: it <em>looks</em> like someone cared about it. The cover banner, the colored section headers, the styled callout boxes — these aren’t just decoration. They signal that the author thought carefully about communication, not just computation.</p><p>But the formatting tricks used in those notebooks work just as well in GitHub READMEs, Jupyter Lab sessions, VS Code notebooks, and HackMD documents. Markdown is Markdown. HTML is HTML.</p><p>This guide collects every useful pattern from a scan of 500+ data science notebooks, organized by type, with working code you can copy directly.</p><h3>1. The Markdown Baseline (Works Everywhere)</h3><p>Before reaching for HTML, know your Markdown fundamentals. These render identically in Kaggle, GitHub, Jupyter, and VS Code:</p><h3>Headings create structure AND navigation</h3><pre># H1 — Title / major chapter<br>## H2 — Section header  <br>### H3 — Sub-section<br>#### H4 — Minor heading</pre><p>GitHub auto-generates a table of contents from headings. Kaggle and Jupyter create anchor links you can target with [link](#section-name).</p><p><strong>Rule:</strong> Never skip a heading level. Going from # to ### without ## breaks visual hierarchy and navigation.</p><h3>Text formatting</h3><pre>**bold text**          &lt;!-- strong emphasis --&gt;<br>*italic text*          &lt;!-- light emphasis --&gt;<br>~~strikethrough~~      &lt;!-- deprecated, removed --&gt;<br>`inline code`          &lt;!-- function names, file paths --&gt;<br>&gt; blockquote           &lt;!-- important notes, quotes --&gt;</pre><h3>GitHub Flavored Markdown (GFM) tables</h3><pre>| Model     | CV Score | LB Score |<br>|-----------|----------|----------|<br>| LightGBM  | 0.9797   | 0.9810   |<br>| XGBoost   | 0.9789   | 0.9801   |<br>| Ensemble  | **0.9814**   | **0.9831**   |</pre><p>These render in GitHub READMEs, Kaggle, and Jupyter with proper alignment.</p><h3>Fenced code blocks with syntax highlighting</h3><pre>```python<br>import lightgbm as lgb<br>model = lgb.LGBMClassifier(n_estimators=1000, learning_rate=0.05)<br>```</pre><p>Use the language identifier (python, sql, bash, json, html) for syntax highlighting.</p><h3>2. HTML in Markdown: What You Can Do</h3><p>Jupyter-based platforms (Kaggle, Jupyter Lab, VS Code notebooks) render a wide subset of HTML directly in Markdown cells. This unlocks everything from colored text to full-page banner designs.</p><h3>What works (everywhere except GitHub READMEs)</h3><ul><li>&lt;div&gt;, &lt;span&gt;, &lt;p&gt; with inline style=&quot;&quot; attributes</li><li>CSS gradients, border-radius, box-shadow, grid, flexbox</li><li>&lt;table&gt; with custom styling</li><li>&lt;details&gt; / &lt;summary&gt; collapsible blocks</li><li>&lt;kbd&gt; keyboard key display</li><li>&lt;a id=&quot;anchor&quot;&gt; for internal navigation</li><li>&lt;img&gt; with external URLs</li><li>&lt;style&gt; blocks (including @import for Google Fonts)</li><li>HTML entities (&amp;nbsp;, &amp;mdash;, &amp;copy;, etc.)</li></ul><h3>What GitHub READMEs strip</h3><p>GitHub’s HTML sanitizer removes style=&quot;&quot; attributes and &lt;style&gt; blocks. For GitHub, stick to Markdown tables and ![badge](shields.io URL) images.</p><h3>3. Cover Banners: The Most Impactful Single Element</h3><p>The notebook cover is the first thing readers see. A well-designed cover communicates competence before they read a single line of code.</p><h3>Pattern 1: Dark Gradient (most common in top notebooks)</h3><pre>&lt;div style=&quot;<br>    background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #0f4c75 100%);<br>    border-left: 6px solid #14B8A6;<br>    border-radius: 16px;<br>    padding: 44px 48px;<br>    box-shadow: 0 10px 30px rgba(0,0,0,0.5);<br>    color: #f9fafb;<br>    font-family: &#39;Segoe UI&#39;, sans-serif;<br>&quot;&gt;<br>    &lt;h1 style=&quot;font-size:36px; font-weight:800; margin:0 0 12px 0;&quot;&gt;<br>        🏆 Your Notebook Title<br>    &lt;/h1&gt;<br>    &lt;p style=&quot;color:#94a3b8; font-size:15px; margin:0 0 20px 0;&quot;&gt;<br>        Competition or project description<br>    &lt;/p&gt;<br>    &lt;div style=&quot;display:flex; gap:16px; flex-wrap:wrap; font-size:13px; color:#94a3b8;&quot;&gt;<br>        &lt;span&gt;👤 &lt;strong style=&quot;color:#f9fafb;&quot;&gt;Author&lt;/strong&gt;&lt;/span&gt;<br>        &lt;span&gt;📅 &lt;strong style=&quot;color:#f9fafb;&quot;&gt;2025&lt;/strong&gt;&lt;/span&gt;<br>        &lt;span&gt;🏅 &lt;strong style=&quot;color:#f9fafb;&quot;&gt;Metric&lt;/strong&gt;&lt;/span&gt;<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><h3>Pattern 2: Photo Background with Dark Overlay</h3><pre>&lt;div style=&quot;<br>    background-image:<br>        linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.70)),<br>        url(https://medium.com/&#39;https://images.unsplash.com/photo-XXXX?w=1200&amp;auto=format&amp;fit=crop&#39;);<br>    background-position: center;<br>    background-size: cover;<br>    padding: 60px 20px;<br>    border-radius: 18px;<br>    text-align: center;<br>&quot;&gt;<br>    &lt;h1 style=&quot;color:white; font-size:36px; text-shadow:2px 2px 8px rgba(0,0,0,0.8);&quot;&gt;<br>        Notebook Title<br>    &lt;/h1&gt;<br>    &lt;p style=&quot;color:rgba(255,255,255,0.85); font-size:16px;&quot;&gt;Subtitle&lt;/p&gt;<br>&lt;/div&gt;</pre><p>Use <a href="https://unsplash.com/">Unsplash</a> for free photos. The rgba(0,0,0,0.55) overlay ensures text stays readable over any image.</p><h3>Pattern 3: Light Earth Tone (from high-scoring notebooks)</h3><pre>&lt;div style=&quot;<br>    background: linear-gradient(135deg, #F7F3EA 0%, #EEF4ED 100%);<br>    border: 1px solid #D9D0BF;<br>    border-radius: 18px;<br>    padding: 30px;<br>&quot;&gt;<br>    &lt;div style=&quot;font-size:11px; letter-spacing:0.14em; text-transform:uppercase;<br>                color:#7A6045; font-weight:700; margin-bottom:8px;&quot;&gt;<br>        Subtitle Label<br>    &lt;/div&gt;<br>    &lt;div style=&quot;font-size:2rem; font-weight:800; color:#233127;&quot;&gt;<br>        Main Heading<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><p>This light variant works well when your notebook uses light-mode matplotlib plots.</p><h3>4. Section Headers: Dividing Long Notebooks</h3><p>Long notebooks need clear visual section breaks. Here are the three most common patterns extracted from competition notebooks:</p><h3>Left-border section header (clean and readable)</h3><pre>&lt;div style=&quot;<br>    padding: 12px 22px;<br>    background: linear-gradient(90deg, #0f2d24 0%, #0d4a3e 100%);<br>    border-radius: 10px;<br>    border-left: 5px solid #84cc16;<br>    color: #f9fafb;<br>    margin: 28px 0 14px 0;<br>&quot;&gt;<br>    &lt;div style=&quot;font-size:20px; font-weight:600;&quot;&gt;2. Exploratory Data Analysis&lt;/div&gt;<br>&lt;/div&gt;</pre><h3>Inline heading-div fusion (for Kaggle/Jupyter only)</h3><pre># &lt;div style=&quot;text-align:center; border-radius:15px; padding:15px;<br>     background:linear-gradient(90deg,#0f2027,#2c5364); color:white;&quot;&gt;<br>&lt;b&gt;Section Title&lt;/b&gt;&lt;/div&gt;</pre><p>This embeds a CSS-styled div inside a Markdown heading tag. The heading still acts as a navigable anchor, but displays with full CSS styling.</p><h3>ALL-CAPS minimal divider</h3><pre>&lt;p style=&quot;font-size:11px; letter-spacing:3px; text-transform:uppercase;<br>          color:#64748b; font-weight:700; border-bottom:1px solid #e2e8f0;<br>          padding-bottom:6px; margin:28px 0 14px 0;&quot;&gt;<br>    ⬛ Section 4 · Model Training<br>&lt;/p&gt;</pre><h3>5. Callout Boxes: Highlighting Key Information</h3><p>Callout boxes catch the reader’s eye for important notes, warnings, or insights.</p><h3>Bootstrap-style alerts (the quick way)</h3><p>Most notebook platforms support &lt;div class=&quot;alert alert-info&quot;&gt; Bootstrap-style alerts natively:</p><pre>&lt;div class=&quot;alert alert-success&quot;&gt;<br>    &lt;strong&gt;✅ Key Result:&lt;/strong&gt; Ensemble achieved CV 0.9814 — top 3% of leaderboard.<br>&lt;/div&gt;</pre><pre>&lt;div class=&quot;alert alert-warning&quot;&gt;<br>    &lt;strong&gt;⚠️ Warning:&lt;/strong&gt; Target encoding must be fit inside CV folds to prevent leakage.<br>&lt;/div&gt;</pre><pre>&lt;div class=&quot;alert alert-danger&quot;&gt;<br>    &lt;strong&gt;🚨 Critical:&lt;/strong&gt; This dataset has severe class imbalance (3.3% positive class).<br>&lt;/div&gt;</pre><h3>Custom styled callouts (for dark notebooks)</h3><pre>&lt;div style=&quot;<br>    background: #1e293b;<br>    border: 1px solid #334155;<br>    border-left: 4px solid #60a5fa;<br>    padding: 14px 18px;<br>    border-radius: 0 8px 8px 0;<br>    font-family: &#39;Segoe UI&#39;, sans-serif;<br>&quot;&gt;<br>    &lt;strong style=&quot;color:#93c5fd;&quot;&gt;📌 Note&lt;/strong&gt;&lt;br&gt;<br>    &lt;span style=&quot;color:#cbd5e1; font-size:14px;&quot;&gt;Your important note here.&lt;/span&gt;<br>&lt;/div&gt;</pre><h3>Metric cards grid</h3><pre>&lt;div style=&quot;display:grid; grid-template-columns:repeat(4,1fr); gap:12px; font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;div style=&quot;background:linear-gradient(135deg,#1e3a5f,#2563eb); border-radius:10px;<br>                padding:16px; text-align:center; color:white;&quot;&gt;<br>        &lt;div style=&quot;font-size:26px; font-weight:800;&quot;&gt;75,000&lt;/div&gt;<br>        &lt;div style=&quot;font-size:12px; opacity:0.8; text-transform:uppercase; letter-spacing:1px; margin-top:4px;&quot;&gt;Training Rows&lt;/div&gt;<br>    &lt;/div&gt;<br>    &lt;!-- Repeat for other metrics --&gt;<br>&lt;/div&gt;</pre><h3>6. Progress Bars and Visual Indicators</h3><p>Progress bars are a clean alternative to tables when you want to show relative scores at a glance:</p><pre>&lt;div style=&quot;font-family:&#39;Segoe UI&#39;,sans-serif; margin:12px 0;&quot;&gt;<br>    &lt;div style=&quot;margin:6px 0;&quot;&gt;<br>        &lt;div style=&quot;display:flex; justify-content:space-between; font-size:13px; margin-bottom:3px;&quot;&gt;<br>            &lt;span&gt;LightGBM&lt;/span&gt;&lt;span&gt;97.97%&lt;/span&gt;&lt;/div&gt;<br>        &lt;div style=&quot;background:#e2e8f0; border-radius:6px; height:12px;&quot;&gt;<br>            &lt;div style=&quot;background:#2563eb; width:97.97%; height:12px; border-radius:6px;&quot;&gt;&lt;/div&gt;<br>        &lt;/div&gt;<br>    &lt;/div&gt;<br>    &lt;div style=&quot;margin:6px 0;&quot;&gt;<br>        &lt;div style=&quot;display:flex; justify-content:space-between; font-size:13px; margin-bottom:3px;&quot;&gt;<br>            &lt;span&gt;Ensemble&lt;/span&gt;&lt;span style=&quot;font-weight:700;&quot;&gt;98.14% 🏆&lt;/span&gt;&lt;/div&gt;<br>        &lt;div style=&quot;background:#e2e8f0; border-radius:6px; height:12px;&quot;&gt;<br>            &lt;div style=&quot;background:linear-gradient(90deg,#16a34a,#4ade80); width:98.14%; height:12px; border-radius:6px;&quot;&gt;&lt;/div&gt;<br>        &lt;/div&gt;<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><h3>7. Badges (Shields.io) — GitHub-Friendly Metadata</h3><p>Badges are SVG images generated by <a href="https://shields.io/">Shields.io</a> and embedded as regular Markdown images. They work in GitHub READMEs, Kaggle notebooks, and everywhere else.</p><pre>![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&amp;logoColor=white)<br>![License](https://img.shields.io/badge/License-MIT-green)<br>![CV Score](https://img.shields.io/badge/CV_Score-0.9797-brightgreen)<br>![Kaggle](https://img.shields.io/badge/Kaggle-20BEFF?logo=kaggle&amp;logoColor=white)</pre><p>Render as: inline colorful badges visible in any platform.</p><p>Common badge patterns:</p><ul><li>?style=flat (default) or ?style=flat-square for square corners</li><li>Add logo: ?logo=python&amp;logoColor=white</li><li>Custom colors: use hex (-1e3a5f) or named colors (-brightgreen, -blue, -red)</li></ul><h3>8. Keyboard Tags, Entities, and Other HTML Gems</h3><h3>&lt;kbd&gt; keyboard shortcut display</h3><pre>Press &lt;kbd&gt;Shift&lt;/kbd&gt; + &lt;kbd&gt;Enter&lt;/kbd&gt; to run a cell.</pre><p>Renders as styled keyboard keys that look like actual key caps.</p><h3>HTML entities for special characters</h3><p>IntentCodeOutputForce space / indent&amp;nbsp;(non-breaking space)Show &lt; literally&amp;lt;&lt;Em dash in prose&amp;mdash;—Copyright&amp;copy;©Right arrow&amp;rarr;→</p><h3>&lt;details&gt; collapsible sections</h3><pre>&lt;details&gt;<br>&lt;summary style=&quot;cursor:pointer; padding:10px 14px; background:#f1f5f9;<br>                border-radius:6px; font-weight:600;&quot;&gt;<br>    ▶ Click to expand: Parameter details<br>&lt;/summary&gt;<br>&lt;div style=&quot;padding:14px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:0 0 6px 6px;&quot;&gt;<br>    Your expanded content here.<br>&lt;/div&gt;<br>&lt;/details&gt;</pre><p>Works in Kaggle, GitHub, Jupyter, VS Code, and HackMD.</p><h3>9. Multi-Column Layouts</h3><p>CSS Grid makes side-by-side content easy in any notebook platform that supports inline CSS:</p><pre>&lt;div style=&quot;display:grid; grid-template-columns:1fr 1fr; gap:16px;&quot;&gt;<br>    &lt;div style=&quot;background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:16px;&quot;&gt;<br>        &lt;h4 style=&quot;margin:0 0 8px 0; color:#1e3a5f;&quot;&gt;Left Column&lt;/h4&gt;<br>        &lt;p style=&quot;margin:0; font-size:14px; color:#64748b;&quot;&gt;Content here&lt;/p&gt;<br>    &lt;/div&gt;<br>    &lt;div style=&quot;background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:16px;&quot;&gt;<br>        &lt;h4 style=&quot;margin:0 0 8px 0; color:#1e3a5f;&quot;&gt;Right Column&lt;/h4&gt;<br>        &lt;p style=&quot;margin:0; font-size:14px; color:#64748b;&quot;&gt;Content here&lt;/p&gt;<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><p>Note: CSS grid is stripped by GitHub’s sanitizer. Use a Markdown table or plain paragraphs for GitHub READMEs.</p><h3>10. Platform Compatibility Reference</h3><p>Not all HTML works everywhere. Here’s a quick reference:</p><p>FeatureKaggleJupyterGitHub READMEVS CodeHackMDInline CSS style=&quot;&quot;✅✅❌ stripped✅✅&lt;style&gt; block✅✅❌ stripped✅✅CSS grid / flexbox✅✅❌✅✅&lt;details&gt; collapsible✅✅✅✅✅LaTeX math✅✅❌⚠️ ext✅Shields.io badges✅✅✅✅✅External images✅✅✅✅✅Google Fonts @import✅ (internet)✅❌⚠️✅</p><p><strong>Key rule:</strong> Write for Kaggle/Jupyter’s full HTML support, then simplify for GitHub by removing inline styles and &lt;style&gt; blocks.</p><h3>11. Complete Notebook Template</h3><p>Here’s a minimal but well-formatted notebook starter you can drop into any data science project:</p><p><strong>Cell 1 — Cover (Markdown):</strong></p><pre>&lt;a id=&quot;top&quot;&gt;&lt;/a&gt;<br>&lt;div style=&quot;background:linear-gradient(135deg,#0f172a,#1e3a5f,#0f4c75);<br>            border-left:6px solid #14B8A6; border-radius:16px; padding:44px 48px;<br>            box-shadow:0 10px 30px rgba(0,0,0,0.5); color:#f9fafb;<br>            font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;h1 style=&quot;font-size:34px; font-weight:800; margin:0 0 10px 0;&quot;&gt;🏆 Your Notebook Title&lt;/h1&gt;<br>    &lt;p style=&quot;color:#94a3b8; font-size:15px; margin:0 0 20px 0;&quot;&gt;<br>        Project description · Metric · Date<br>    &lt;/p&gt;<br>    &lt;div style=&quot;display:flex; gap:16px; flex-wrap:wrap; font-size:13px; color:#94a3b8;&quot;&gt;<br>        &lt;span&gt;👤 &lt;strong style=&quot;color:#f9fafb;&quot;&gt;Your Name&lt;/strong&gt;&lt;/span&gt;<br>        &lt;span&gt;📅 &lt;strong style=&quot;color:#f9fafb;&quot;&gt;2025&lt;/strong&gt;&lt;/span&gt;<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><p><strong>Cell 2 — Section Header (Markdown):</strong></p><pre>&lt;div style=&quot;padding:12px 20px; background:linear-gradient(90deg,#1e3a5f,#2563eb);<br>            border-radius:10px; color:white; margin:20px 0 10px 0;&quot;&gt;<br>    &lt;h2 style=&quot;margin:0; font-size:18px; font-weight:700;&quot;&gt;1. Introduction&lt;/h2&gt;<br>&lt;/div&gt;</pre><p><strong>Cell 3 — Footer (Markdown):</strong></p><pre>&lt;center&gt;<br>&lt;div style=&quot;display:inline-block; padding:10px 24px; border-radius:8px;<br>            background:linear-gradient(90deg,#1e3a5f,#2563eb); color:white;<br>            font-family:&#39;Segoe UI&#39;,sans-serif; font-size:15px;&quot;&gt;<br>    &lt;strong&gt;👍 If this helped you, please upvote!&lt;/strong&gt;<br>&lt;/div&gt;<br>&lt;/center&gt;</pre><h3>12. Advanced Patterns: Narrative Templates &amp; Dynamic Elements</h3><p>These patterns come from a deeper scan of top-performing competition notebooks. They solve a specific problem: how do you make a long analytical notebook <em>readable</em> — not just technically correct?</p><h3>12.1 Dashed-Border Key Findings Box</h3><p>After every major plot, add a dashed annotation box. The dashed border reads as a <em>comment</em> on the chart above — not an alert, not a callout box, but an honest “here’s what I noticed”:</p><pre>&lt;div style=&quot;padding:18px 24px; background:#fafaf9; border:1.5px dashed #a3a3a3;<br>            border-radius:10px; margin:14px 0; font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;p style=&quot;margin:0 0 8px 0; font-size:11px; letter-spacing:2px;<br>              text-transform:uppercase; color:#737373; font-weight:600;&quot;&gt;<br>        ✏ Key findings<br>    &lt;/p&gt;<br>    &lt;ul style=&quot;margin:0; font-size:14px; color:#525252; font-style:italic;<br>               line-height:1.7; padding-left:18px;&quot;&gt;<br>        &lt;li&gt;&lt;b&gt;Feature A&lt;/b&gt; shows the largest class separation.&lt;/li&gt;<br>        &lt;li&gt;Train and test distributions are well-aligned.&lt;/li&gt;<br>    &lt;/ul&gt;<br>&lt;/div&gt;</pre><h3>12.2 Sub-Section Divider Strip</h3><p>A left-border heading strip for three-tier hierarchy — <strong>section pill → sub-section strip → findings box</strong>:</p><pre>&lt;div style=&quot;padding:8px 18px; border-left:4px solid #14b8a6;<br>            background:#f0fdf4; border-radius:0 8px 8px 0; margin:18px 0 10px 0;&quot;&gt;<br>    &lt;h3 style=&quot;margin:0; color:#0f2d24; font-size:16px; font-weight:700;<br>               font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>        3.1 — Numerical feature distributions<br>    &lt;/h3&gt;<br>&lt;/div&gt;</pre><p>Available in three variants: teal (default analysis), blue (informational), amber (important/warning).</p><h3>12.3 Faded-Prefix Numbered Section Header</h3><p>Dark gradient pill where the number is at reduced opacity — creates magazine-style editorial section breaks:</p><pre>&lt;div style=&quot;padding:12px 22px; background:linear-gradient(90deg,#0f2d24,#0d4a3e);<br>            border-radius:10px; border-left:5px solid #84cc16; color:#f9fafb;<br>            margin:28px 0 14px 0; font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;div style=&quot;font-size:20px; font-weight:600;&quot;&gt;<br>        &lt;span style=&quot;opacity:0.45; font-weight:500; margin-right:12px;&quot;&gt;01&lt;/span&gt;<br>        Data Quality &amp;amp; Structure<br>    &lt;/div&gt;<br>&lt;/div&gt;</pre><h3>12.4 Score Metric Mini-Cards</h3><p>Horizontal flexbox strip for displaying model results — each card has a color-coded top border matching the metric type:</p><pre>&lt;div style=&quot;display:flex; gap:12px; flex-wrap:wrap; margin:16px 0;<br>            font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;div style=&quot;flex:1; min-width:130px; background:#1e293b; border-radius:10px;<br>                padding:14px 18px; border-top:3px solid #2563eb; text-align:center;&quot;&gt;<br>        &lt;div style=&quot;font-size:11px; color:#94a3b8; text-transform:uppercase;<br>                    letter-spacing:1.5px; margin-bottom:6px;&quot;&gt;CV Score&lt;/div&gt;<br>        &lt;div style=&quot;font-size:26px; font-weight:800; color:#60a5fa;&quot;&gt;0.9797&lt;/div&gt;<br>        &lt;div style=&quot;font-size:11px; color:#64748b; margin-top:4px;&quot;&gt;5-Fold Stratified KFold&lt;/div&gt;<br>    &lt;/div&gt;<br>    &lt;!-- Add more cards: LB Score (teal), Models (purple), Top % (amber) --&gt;<br>&lt;/div&gt;</pre><h3>12.5 HTML Model Comparison Table</h3><p>Styled leaderboard with gradient dark header and alternating row shading. Supports emoji medals and per-column color coding — none of which Markdown tables can do:</p><pre>&lt;table style=&quot;border-collapse:collapse; width:100%; font-size:14px; margin:16px 0;<br>              font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>    &lt;thead&gt;<br>        &lt;tr style=&quot;background:linear-gradient(90deg,#1e3a5f,#2563eb); color:#f1f5f9;&quot;&gt;<br>            &lt;th style=&quot;padding:11px 16px; text-align:left;&quot;&gt;Model&lt;/th&gt;<br>            &lt;th style=&quot;padding:11px 16px; text-align:center;&quot;&gt;CV Score&lt;/th&gt;<br>            &lt;th style=&quot;padding:11px 16px; text-align:center;&quot;&gt;LB Score&lt;/th&gt;<br>        &lt;/tr&gt;<br>    &lt;/thead&gt;<br>    &lt;tbody&gt;<br>        &lt;tr style=&quot;background:#f8fafc;&quot;&gt;<br>            &lt;td style=&quot;padding:10px 16px; border-bottom:1px solid #e2e8f0;&quot;&gt;🥇 Ensemble&lt;/td&gt;<br>            &lt;td style=&quot;padding:10px 16px; text-align:center; color:#16a34a; font-weight:700;&quot;&gt;0.9797&lt;/td&gt;<br>            &lt;td style=&quot;padding:10px 16px; text-align:center; color:#16a34a; font-weight:700;&quot;&gt;0.9774&lt;/td&gt;<br>        &lt;/tr&gt;<br>    &lt;/tbody&gt;<br>&lt;/table&gt;</pre><h3>12.6 Dynamic Dataset Summary Strip (Python → HTML)</h3><p>A Python function that emits an HTML banner inline, between data preview cells. The banner shows row count, column count, and memory usage — all computed dynamically from the actual DataFrame:</p><pre>from IPython.display import HTML, display</pre><pre>def dataset_summary(df, label=&quot;Dataset&quot;):<br>    display(HTML(f&quot;&quot;&quot;<br>    &lt;div style=&quot;border-left:5px solid #2563eb; padding:10px 18px;<br>                background:#eff6ff; border-radius:0 8px 8px 0; margin:12px 0;<br>                font-family:&#39;Segoe UI&#39;,sans-serif;&quot;&gt;<br>        &lt;b style=&quot;color:#1e3a5f;&quot;&gt;{label}&lt;/b&gt;<br>        &lt;span style=&quot;margin-left:14px; color:#2563eb; font-weight:600;&quot;&gt;{df.shape[0]:,} rows&lt;/span&gt;<br>        &lt;span style=&quot;margin-left:10px; color:#64748b;&quot;&gt;×&lt;/span&gt;<br>        &lt;span style=&quot;margin-left:10px; color:#2563eb; font-weight:600;&quot;&gt;{df.shape[1]} columns&lt;/span&gt;<br>        &lt;span style=&quot;margin-left:16px; color:#94a3b8; font-size:12px;&quot;&gt;<br>            memory: {df.memory_usage(deep=True).sum() / 1024**2:.1f} MB<br>        &lt;/span&gt;<br>    &lt;/div&gt;&quot;&quot;&quot;))<br>    return df</pre><pre># Use as a wrapper — returns the dataframe so .head() chaining still works<br>dataset_summary(train_df, &quot;Training Set&quot;).head()</pre><h3>12.7 Hypothesis → Inference Blockquote Chain</h3><p>A pure Markdown pattern for feature engineering rationale. The 👉 emoji acts as a visual pointer; the &gt; blockquote highlights the core claim:</p><pre>### 🔗 Interaction Features</pre><pre>Based on EDA: extreme values in a single feature rarely predict the target alone.</pre><pre>👉 **Hypothesis:**</pre><pre>&gt; If variable_A is high **and** variable_B is low → the target outcome is likely Class 1.</pre><pre>We therefore created a ratio feature: `variable_A / (variable_B + 1e-6)`</pre><p>This creates a clear audit trail: every engineered feature has a documented reason for existing.</p><h3>12.8 ELI5 + Section Insight Template</h3><p>The most consistent structural pattern in highly-voted notebooks: every major analysis block starts with a plain-English <strong>ELI5 preamble</strong> and ends with a 💡 Insight footer.</p><pre>## 📦 Section 3 — Numerical Feature Distributions</pre><pre>**ELI5:** For each numerical feature, we want to know:<br>- What values are typical? (mean, median)<br>- Are train and test distributions similar?</pre><pre>[... code + plots ...]</pre><pre>---</pre><pre>### 💡 Section 3 Insight</pre><pre>**Uniform kurtosis signature:** All excess kurtosis values cluster near −1.2.<br>This tells us features were sampled independently and uniformly.</pre><pre>**Implication for modeling:**<br>- No imputation needed<br>- Feature interactions will matter more than raw feature values</pre><p>The ELI5 pattern serves two audiences simultaneously: beginners who need context, and experts who skip straight to the Insight footer.</p><h3>Summary</h3><p>The patterns in this guide were extracted directly from 500+ Kaggle competition notebooks and validated across multiple platforms. The key takeaways:</p><ol><li><strong>Markdown first</strong> — everything in section 1 works everywhere</li><li><strong>Inline CSS</strong> works in Kaggle, Jupyter, VS Code, and HackMD but is stripped by GitHub</li><li><strong>CSS grid/flexbox</strong> unlocks multi-column layouts in notebooks</li><li><strong>A cover banner</strong> takes 10 minutes and makes your notebook look 10× more professional</li><li><strong>Section headers</strong> help readers navigate long notebooks — use left-border divs or # &lt;div&gt; fusion</li><li><strong>Shields.io badges</strong> are the only way to add styled metadata that works in GitHub READMEs</li><li><strong>&lt;details&gt;</strong> is the one HTML element that GitHub does render — use it for collapsible content</li><li><strong>ELI5 + Insight structure</strong> makes your notebook useful to both beginners and experts — and forces you to articulate what you actually found</li></ol><p>The complete catalog notebook with rendered examples of every pattern above is available at: <a href="https://www.kaggle.com/code/huseyincenik/markdown-html-reference-catalog">Kaggle — Markdown &amp; HTML Reference Catalog</a>. You can visit <a href="https://www.linkedin.com/pulse/complete-visual-guide-markdown-html-formatting-data-science-cenik-hveqf/">Linkedin </a>or <a href="https://github.com/huseyincenik/markdown_html_reference_catalog">Github.</a></p><p><em>All patterns tested on data science competition notebooks. Code examples are production-ready and copy-paste safe.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1bdc98bfa4b7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Text Data Clustering Workflow: Preprocessing, Vectorization, Dimensionality Reduction & Evaluation…]]></title>
            <link>https://medium.com/@huseyinceniik/text-data-clustering-workflow-preprocessing-vectorization-dimensionality-reduction-evaluation-bae5fe4c34ff?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/bae5fe4c34ff</guid>
            <category><![CDATA[clustering]]></category>
            <category><![CDATA[nlp]]></category>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Wed, 22 Apr 2026 08:01:01 GMT</pubDate>
            <atom:updated>2026-04-22T08:01:01.671Z</atom:updated>
            <content:encoded><![CDATA[<h3>Text Data Clustering Workflow: Preprocessing, Vectorization, Dimensionality Reduction &amp; Evaluation 📊📈 | Improve Your Model with Silhouette, Elbow, and Inertia Metrics 🔍</h3><p>Text data has become one of the most complex yet rewarding areas in data science today. To organize and derive meaningful insights from it, text clustering algorithms play a pivotal role in Natural Language Processing (NLP). This article dives into the <strong>methods</strong>, <strong>applications</strong>, and <strong>performance evaluations</strong> of a complete text clustering workflow — providing detailed explanations and reproducible code for each technique.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Fe_dcl92T8Tj_8lNbMjSZw.png" /><figcaption><strong><em>Fig 1.</em></strong><em> Workflow diagram illustrating the process of text data preprocessing, vectorization, dimensionality reduction, evaluating data suitability, applying clustering algorithms, and evaluating cluster quality.</em></figcaption></figure><h3>OVERVIEW</h3><p>Text clustering is a complex task that involves several critical steps to ensure meaningful and accurate results. Each phase plays a vital role in transforming raw text into actionable structure. A methodical approach is crucial, as the effectiveness of the clustering largely depends on how well each phase is executed.</p><p>Below is the complete pipeline this article walks through:</p><pre>Raw Text Data<br>     │<br>     ▼<br> 1. Text Preprocessing<br>     │  cleaning · normalization · NER · fuzzy deduplication<br>     ▼<br> 2. Vectorization<br>     │  TF-IDF · Sentence Transformers<br>     ▼<br> 3. Dimensionality Reduction<br>     │  t-SNE<br>     ▼<br> 4. Clustering Tendency Assessment<br>     │  Hopkins Statistic<br>     ▼<br> 5. Optimal k Selection<br>     │  Elbow · Silhouette · Davies-Bouldin · Calinski-Harabasz<br>     ▼<br> 6. Cluster Validation<br>     │  Yellowbrick SilhouetteVisualizer<br>     ▼<br> 7. Cluster Labeling<br>        LLM (GPT-4o-mini)</pre><blockquote><strong><em>Fig 1.</em></strong><em> Workflow diagram illustrating the process of text data preprocessing, vectorization, dimensionality reduction, evaluating data suitability, applying clustering algorithms, and evaluating cluster quality.</em></blockquote><p>Let’s go through each step.</p><h3>Step 1 — Text Preprocessing</h3><p>Garbage in, garbage out. No embedding model or clustering algorithm can compensate for noisy, inconsistent raw text. Preprocessing is where the quality of your entire pipeline is decided.</p><h3>1.1 Basic Cleaning and Normalization</h3><p>A general-purpose cleaning function should handle the most common issues in real-world text: leading/trailing noise words, stray punctuation, inconsistent whitespace, and inconsistent casing.</p><pre>import re<br><br>def clean_text(text: str) -&gt; str:<br>    # Remove leading conjunctions like &quot;and&quot;, &quot;or&quot; (common in list entries)<br>    text = re.sub(r&#39;^\s*(?i)(and|or)\s+&#39;, &#39;&#39;, text)<br>    # Keep only alphanumeric characters and safe symbols<br>    text = re.sub(r&#39;[^\w\s\(\)/\-,]&#39;, &#39;&#39;, text)<br>    # Collapse multiple spaces into one<br>    text = re.sub(r&#39;\s+&#39;, &#39; &#39;, text)<br>    # Strip leading/trailing punctuation and whitespace<br>    text = text.strip(&#39;,&quot; &#39;)<br>    # Title-case for consistent token representation<br>    return text.title().strip()</pre><p><strong>Why title-case?</strong> Downstream steps like Named Entity Recognition (NER) and fuzzy matching are case-sensitive. Normalizing to title-case ensures &quot;machine learning&quot;, &quot;Machine Learning&quot;, and &quot;MACHINE LEARNING&quot; all map to the same token.</p><h3>1.2 Multi-Value Splitting</h3><p>Raw text columns often contain multiple values packed into a single cell (comma-separated, semicolon-separated, etc.). For clustering, each atomic unit should be its own row:</p><pre>import pandas as pd<br><br>def explode_column(df: pd.DataFrame, col: str, sep: str = &quot;,&quot;) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;Split a delimited text column into one row per value.&quot;&quot;&quot;<br>    df = df.copy()<br>    df[col] = df[col].apply(clean_text)<br>    df[col] = df[col].str.split(sep)<br>    df = df.explode(col)<br>    df[col] = df[col].str.strip()<br>    df = df[df[col] != &quot;&quot;].reset_index(drop=True)<br>    return df<br>df_clean = explode_column(df, col=&quot;text_column&quot;)<br>print(f&quot;Rows after splitting: {len(df_clean)}&quot;)</pre><h3>1.3 Named Entity Recognition with <a href="https://github.com/urchade/GLiNER">GLiNER</a></h3><p>One of the most powerful preprocessing techniques for domain-rich text is <strong>Named Entity Recognition (NER)</strong>. When text entries are long, verbose descriptions, NER can extract the canonical terms that actually carry clustering signal — discarding the filler words around them.</p><p><strong>GLiNER</strong> (Generalist and Lightweight Model for Named Entity Recognition) is an excellent choice here because it supports <strong>zero-shot, user-defined entity labels</strong>. You define what you’re looking for; GLiNER finds it — no fine-tuning required.</p><pre># pip install gliner<br>from gliner import GLiNER<br><br># Load a pretrained GLiNER model<br>model = GLiNER.from_pretrained(&quot;urchade/gliner_mediumv2.1&quot;)<br># Define your domain&#39;s entity labels<br>labels = [<br>    &quot;Product Category&quot;,<br>    &quot;Technology&quot;,<br>    &quot;Service Type&quot;,<br>    &quot;Topic&quot;,<br>    &quot;Domain&quot;,<br>    # Add as many domain-specific labels as needed<br>]<br>def extract_entities(text: str, threshold: float = 0.8) -&gt; str:<br>    &quot;&quot;&quot;Extract canonical entity mentions from a text string.&quot;&quot;&quot;<br>    entities = model.predict_entities(text, labels, threshold=threshold)<br><br>    # Deduplicate and join extracted terms<br>    terms = set(entity[&quot;text&quot;] for entity in entities)<br>    return &quot;, &quot;.join(terms)</pre><p>Apply NER selectively — only on entries long enough to need normalization. Short entries (e.g., 1–3 words) are already concise:</p><pre>df_clean[&quot;word_count&quot;] = df_clean[&quot;text_column&quot;].apply(lambda x: len(x.split()))<br><br># Only apply NER to long entries<br>df_clean[&quot;entities&quot;] = df_clean.apply(<br>    lambda row: extract_entities(row[&quot;text_column&quot;]) if row[&quot;word_count&quot;] &gt; 8 else &quot;&quot;,<br>    axis=1<br>)<br><br># Replace verbose entries with their extracted entities<br>mask = df_clean[&quot;entities&quot;] != &quot;&quot;<br>df_clean.loc[mask, &quot;text_column&quot;] = df_clean.loc[mask, &quot;entities&quot;]<br><br># Drop entries that are still too long after NER<br>df_clean[&quot;word_count&quot;] = df_clean[&quot;text_column&quot;].apply(lambda x: len(x.split()))<br>df_clean = df_clean[df_clean[&quot;word_count&quot;] &lt;= 8].reset_index(drop=True)</pre><blockquote><em>💡 </em><strong><em>Why threshold=0.8?</em></strong><em> Lower thresholds produce more entities but with more false positives. For most domains, 0.8 gives a good balance between recall and precision. Tune this based on manual inspection.</em></blockquote><h3>1.4 Fuzzy Deduplication with <a href="https://github.com/rapidfuzz/RapidFuzz">RapidFuzz</a></h3><p>After cleaning and NER, near-duplicate strings still remain — e.g., &quot;Machine Learning&quot; vs &quot;Machine Learning Methods&quot;, or &quot;Diabetes Type 2&quot; vs &quot;Type 2 Diabetes&quot;. These create artificial fragmentation in your clusters.</p><p><strong>RapidFuzz</strong> provides fast, Levenshtein-based string similarity that scales to large vocabularies:</p><pre># pip install rapidfuzz<br>from rapidfuzz import fuzz<br>from collections import defaultdict<br><br>SIMILARITY_THRESHOLD = 95  # Tune this carefully<br>def build_fuzzy_groups(terms: list, threshold: int = 95) -&gt; defaultdict:<br>    &quot;&quot;&quot;Group near-duplicate strings together by fuzzy similarity.&quot;&quot;&quot;<br>    groups = defaultdict(list)<br>    for term in terms:<br>        matched = False<br>        for group_key in groups:<br>            if fuzz.ratio(term, group_key) &gt;= threshold:<br>                groups[group_key].append(term)<br>                matched = True<br>                break<br>        if not matched:<br>            groups[term].append(term)<br>    return groups<br>unique_terms = list(df_clean[&quot;text_column&quot;].unique())<br>groups = build_fuzzy_groups(unique_terms, threshold=SIMILARITY_THRESHOLD)<br># Show only groups with actual duplicates<br>for key, members in groups.items():<br>    if len(members) &gt; 1:<br>        print(f&quot;Group &#39;{key}&#39;: {members}&quot;)</pre><p>Once groups are built, map every variant back to the canonical group key:</p><pre>def apply_fuzzy_normalization(text: str, groups: defaultdict) -&gt; str:<br>    &quot;&quot;&quot;Replace a term with its group&#39;s canonical representative.&quot;&quot;&quot;<br>    for key, members in groups.items():<br>        if text in members:<br>            return key<br>    return text<br><br>df_clean[&quot;text_column&quot;] = df_clean[&quot;text_column&quot;].apply(<br>    lambda x: apply_fuzzy_normalization(x, groups)<br>)</pre><blockquote><em>⚠️ </em><strong><em>Threshold selection matters.</em></strong><em> A threshold of 80 risks merging genuinely different concepts that happen to share many characters. A threshold of 95–99 is safer for domain-specific vocabularies where subtle differences carry real meaning. Always manually review the generated groups before applying them.</em></blockquote><h3>Step 2 — Vectorization</h3><p>Once the text is clean, it must be converted into numerical vectors that capture semantic meaning. There are two main approaches.</p><h3>2.1 <a href="https://en.wikipedia.org/wiki/Tf%E2%80%93idf">TF-IDF (Term Frequency–Inverse Document Frequency)</a></h3><p>TF-IDF is the classic, lightweight baseline. It produces sparse high-dimensional vectors where each dimension corresponds to a vocabulary token.</p><pre>from sklearn.feature_extraction.text import TfidfVectorizer<br>from nltk.tokenize import word_tokenize<br><br># sublinear_tf=True applies log normalization → prevents high-frequency<br># terms from dominating the vector space<br>tfidf_vectorizer = TfidfVectorizer(<br>    sublinear_tf=True,<br>    tokenizer=word_tokenize,  # Use NLTK tokenizer for better handling<br>    min_df=1<br>)<br>texts = df_clean[&quot;text_column&quot;].unique().tolist()<br>tfidf_matrix = tfidf_vectorizer.fit_transform(texts)<br>print(&quot;TF-IDF shape:&quot;, tfidf_matrix.shape)<br># → (num_unique_terms, vocabulary_size)</pre><p><strong>Strengths</strong>: Fast, interpretable, no GPU needed. <strong>Weakness</strong>: Purely lexical — &quot;automobile&quot; and &quot;car&quot; have zero similarity. Works best when your vocabulary is consistent and controlled.</p><h3>2.2 <a href="https://sbert.net/examples/sentence_transformer/applications/semantic-search/README.html">Sentence Transformers (Dense Semantic Embeddings)</a></h3><p>For richer semantic similarity, <strong>sentence transformers</strong> produce dense 768-dimensional vectors where semantically similar phrases cluster together in vector space — even when they share no common words.</p><pre># pip install sentence-transformers<br>from sentence_transformers import SentenceTransformer<br>import pickle<br><br># Choose a model appropriate for your domain<br># General-purpose:<br>#   all-MiniLM-L6-v2          → fast, small, good for general text<br>#   all-mpnet-base-v2          → higher quality, slightly slower<br># Domain-specific:<br>#   NeuML/pubmedbert-base-embeddings  → biomedical text<br>#   sentence-transformers/multi-qa-mpnet-base-dot-v1  → Q&amp;A / retrieval<br>model = SentenceTransformer(&quot;all-MiniLM-L6-v2&quot;)<br>texts = df_clean[&quot;text_column&quot;].unique().tolist()<br>embeddings = model.encode(texts, show_progress_bar=True)<br>print(&quot;Embeddings shape:&quot;, embeddings.shape)<br><br># → (num_unique_terms, 384) for MiniLM or (num_unique_terms, 768) for larger models<br># Persist to disk - encoding is expensive; avoid recomputing<br>with open(&quot;embeddings.pkl&quot;, &quot;wb&quot;) as f:<br>    pickle.dump(embeddings, f)</pre><p>To reload later:</p><pre>with open(&quot;embeddings.pkl&quot;, &quot;rb&quot;) as f:<br>    embeddings = pickle.load(f)<br>print(&quot;Loaded embeddings:&quot;, embeddings.shape)</pre><blockquote><em>💡 </em><strong><em>General vs domain-specific models</em></strong><em>: A general-purpose model like </em><em>all-MiniLM-L6-v2 is a solid default. If your text is highly specialized (legal, medical, financial), a domain-specific fine-tuned model will significantly improve cluster coherence. Always benchmark a few models against each other using the evaluation metrics covered later in this article.</em></blockquote><p><strong>TF-IDF vs Sentence Transformers — when to use which:</strong></p><p>TF-IDFSentence TransformersSpeedVery fastModerate (GPU helps)Semantic understandingNone — lexical onlyStrongWorks across paraphrasesNoYesMemory footprintSparse (efficient)Dense (larger)Best forControlled vocabularies, keyword-rich corporaFree-form prose, synonym-rich domains</p><h3>Step 3 — Dimensionality Reduction with <a href="https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html">t-SNE</a></h3><p>Dense embeddings live in 768+ dimensional space. Distance-based algorithms like KMeans suffer in very high dimensions — a phenomenon known as the <strong>curse of dimensionality</strong> — where all pairwise distances converge to the same value, making cluster separation meaningless.</p><p><strong>t-SNE</strong> (t-Distributed Stochastic Neighbor Embedding) projects high-dimensional data into 2D while preserving <strong>local neighborhood structure</strong>: points that were close in 768D remain close in 2D.</p><pre>import numpy as np<br>from sklearn.manifold import TSNE<br><br>def reduce_with_tsne(embeddings: np.ndarray, n_components: int = 2,<br>                     random_state: int = 42) -&gt; np.ndarray:<br>    tsne = TSNE(<br>        n_components=n_components,<br>        random_state=random_state,<br>        perplexity=30,       # Controls local vs global structure trade-off<br>        n_iter=1000,         # More iterations → more stable layout<br>        learning_rate=&quot;auto&quot;<br>    )<br>    return tsne.fit_transform(embeddings)<br>reduced = reduce_with_tsne(embeddings)<br>print(&quot;Reduced shape:&quot;, reduced.shape)  # → (n_samples, 2)</pre><p><strong>Visualizing the result:</strong></p><pre>import matplotlib.pyplot as plt<br><br>def plot_tsne(reduced: np.ndarray, labels=None, title=&quot;t-SNE Projection&quot;):<br>    plt.figure(figsize=(12, 8))<br>    scatter = plt.scatter(<br>        reduced[:, 0], reduced[:, 1],<br>        c=labels, cmap=&quot;tab20&quot;, alpha=0.6, s=20<br>    )<br>    if labels is not None:<br>        plt.colorbar(scatter, label=&quot;Cluster&quot;)<br>    plt.title(title)<br>    plt.xlabel(&quot;t-SNE Dimension 1&quot;)<br>    plt.ylabel(&quot;t-SNE Dimension 2&quot;)<br>    plt.grid(True, alpha=0.3)<br>    plt.tight_layout()<br>    plt.show()<br>plot_tsne(reduced)</pre><blockquote><strong><em>t-SNE vs UMAP</em></strong><em>: t-SNE is computationally expensive O(n²) for large datasets. </em><strong><em>UMAP</em></strong><em> is a faster alternative that additionally preserves global structure. For datasets under ~10K samples, t-SNE is the clearer choice; for larger datasets, consider UMAP.</em></blockquote><pre># UMAP alternative (pip install umap-learn)<br>import umap<br><br>reducer = umap.UMAP(n_components=2, random_state=42)<br>reduced_umap = reducer.fit_transform(embeddings)</pre><h3>Step 4 — <a href="https://en.wikipedia.org/wiki/Hopkins_statistic">Clustering Tendency Assessment: The Hopkins Statistic</a></h3><p>Before running any clustering algorithm, you must ask a fundamental question: <strong>does this data actually have cluster structure?</strong> Random, uniformly distributed data can produce arbitrary clusters that mean nothing.</p><p>The <strong>Hopkins statistic</strong> answers this question quantitatively:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*XrsHYs2RsSXbqVgkUErvaw.png" /></figure><p>​​Where:</p><ul><li>ui = distance from a <strong>uniformly random point</strong> to its nearest neighbor in the data</li><li>wi​ = distance from a <strong>real data point</strong> to its nearest neighbor in the data</li></ul><p><strong>Interpretation:</strong></p><p>H valueMeaningH ≈ 0.5Data is random / uniformly distributed — no clustering tendencyH &gt; 0.75Meaningful clustering structure presentH &gt; 0.9Very strong clustering tendency</p><pre>from sklearn.neighbors import NearestNeighbors<br>from random import sample<br>from numpy.random import uniform<br>import numpy as np<br><br>def hopkins_statistic(X: np.ndarray, sampling_ratio: float = 0.05) -&gt; float:<br>    &quot;&quot;&quot;<br>    Compute the Hopkins statistic for clustering tendency.<br>    <br>    Parameters<br>    ----------<br>    X : np.ndarray of shape (n_samples, n_features)<br>    sampling_ratio : float<br>        Fraction of data to sample. Paper by Lawson &amp; Jures recommends 0.05.<br>    <br>    Returns<br>    -------<br>    float : Hopkins statistic H in [0, 1]<br>    &quot;&quot;&quot;<br>    if not isinstance(X, np.ndarray):<br>        X = X.values<br>    n_samples = int(X.shape[0] * sampling_ratio)<br><br>    # Step 1: Draw a uniform random sample across the data space<br>    X_uniform = uniform(X.min(axis=0), X.max(axis=0), (n_samples, X.shape[1]))<br>    <br>    # Step 2: Draw a random sample from the real data<br>    real_indices = sample(range(X.shape[0]), n_samples)<br>    X_real = X[real_indices]<br><br>    # Step 3: Fit a nearest-neighbor index on the full data<br>    nbrs = NearestNeighbors(n_neighbors=2).fit(X)<br><br>    # Distances from uniform random points to their nearest real neighbor<br>    u_dists, _ = nbrs.kneighbors(X_uniform, n_neighbors=2)<br><br>    u_dists = u_dists[:, 0]  # First nearest neighbor<br><br>    # Distances from real points to their nearest real neighbor<br>    # (second neighbor because the first is the point itself, distance=0)<br>    w_dists, _ = nbrs.kneighbors(X_real, n_neighbors=2)<br>    w_dists = w_dists[:, 1]<br>    H = np.sum(u_dists) / (np.sum(u_dists) + np.sum(w_dists))<br>    return H</pre><p>Run it multiple times and average for stability, testing multiple scalers:</p><pre>from sklearn.preprocessing import StandardScaler, MinMaxScaler<br><br># Test with MinMaxScaler<br>scores_minmax = [hopkins_statistic(MinMaxScaler().fit_transform(reduced), ratio=1.0)<br>                 for _ in range(10)]<br>print(f&quot;Hopkins (MinMax)  : {np.mean(scores_minmax):.4f}&quot;)<br><br># Test with StandardScaler<br>scores_standard = [hopkins_statistic(StandardScaler().fit_transform(reduced), ratio=1.0)<br>                   for _ in range(10)]<br>print(f&quot;Hopkins (Standard): {np.mean(scores_standard):.4f}&quot;)<br><br># Final scaled data for clustering<br>X = StandardScaler().fit_transform(reduced)<br>print(f&quot;Hopkins (final)   : {hopkins_statistic(X, ratio=1.0):.4f}&quot;)</pre><blockquote><em>⚠️ </em><strong><em>If H &lt; 0.6</em></strong><em>, stop and re-examine your preprocessing and vectorization. Your data may genuinely lack cluster structure, or your dimensionality reduction may have destroyed it.</em></blockquote><h3>Step 5 — Finding the Optimal Number of Clusters</h3><p>Choosing k in KMeans is one of the most consequential decisions in the pipeline. There is no single correct answer — but using multiple evaluation metrics together gives you a much more reliable picture than any one metric alone.</p><h3>5.1 <a href="https://en.wikipedia.org/wiki/Elbow_method_(clustering)">The Elbow Method (Inertia)</a></h3><p><strong>Inertia</strong> is the within-cluster sum of squared distances from each point to its centroid. It always decreases as k increases, but the rate of decrease slows once the &quot;natural&quot; number of clusters is reached. The elbow point identifies this transition.</p><pre>from sklearn.cluster import KMeans<br>import matplotlib.pyplot as plt<br>import numpy as np<br><br>k_range = range(2, 50)<br>inertias = []<br>for k in k_range:<br>    km = KMeans(n_clusters=k, n_init=100, max_iter=400, random_state=42)<br>    km.fit(X)<br>    inertias.append(km.inertia_)<br># Plot<br>plt.figure(figsize=(10, 5))<br>plt.plot(k_range, inertias, marker=&#39;o&#39;, color=&#39;steelblue&#39;)<br>plt.title(&quot;Elbow Method - Finding Optimal k&quot;)<br>plt.xlabel(&quot;Number of Clusters (k)&quot;)<br>plt.ylabel(&quot;Inertia (Within-Cluster Sum of Squares)&quot;)<br>plt.grid(True, alpha=0.4)<br>plt.tight_layout()<br>plt.show()</pre><p>The elbow is the inflection point — where adding more clusters gives diminishing returns. In practice, the elbow is often not perfectly sharp, which is why you need the other metrics below.</p><h3>5.2 <a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.silhouette_score.html">Silhouette Score</a></h3><p>The <strong>Silhouette Score</strong> measures how similar a data point is to its own cluster compared to the nearest neighboring cluster:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*SA7BXIpNblqfKfhS.png" /></figure><p>Where:</p><ul><li>a(i) = average distance from point i<em>i</em> to all other points <strong>in the same cluster</strong></li><li>b(i) = average distance from point i<em>i</em> to all points in the <strong>nearest other cluster</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/699/1*Ykyu5CU3b8JO3Zle6VJD-A.png" /></figure><pre>from sklearn.metrics import silhouette_score<br><br>silhouette_scores = []<br>for k in k_range:<br>    km = KMeans(n_clusters=k, n_init=100, max_iter=400, random_state=42)<br>    labels = km.fit_predict(X)<br>    silhouette_scores.append(silhouette_score(X, labels))<br>best_k_sil = k_range[np.argmax(silhouette_scores)]<br>print(f&quot;Best k by Silhouette: {best_k_sil} (score={max(silhouette_scores):.4f})&quot;)</pre><h3>5.3 <a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.davies_bouldin_score.html">Davies-Bouldin Index</a></h3><p>The <strong>Davies-Bouldin Index</strong> (DBI) measures the average ratio of within-cluster scatter to between-cluster separation:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/459/1*Z4XfxT41zzAn5QQw_q9GNA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/689/1*E0AX0PYIZf0ruJjhS_h6uA.png" /></figure><pre>from sklearn.metrics import davies_bouldin_score<br><br>db_scores = []<br>for k in k_range:<br>    km = KMeans(n_clusters=k, n_init=100, max_iter=400, random_state=42)<br>    labels = km.fit_predict(X)<br>    db_scores.append(davies_bouldin_score(X, labels))<br>best_k_db = k_range[np.argmin(db_scores)]<br>print(f&quot;Best k by Davies-Bouldin: {best_k_db} (score={min(db_scores):.4f})&quot;)</pre><h3>5.4 <a href="https://scikit-learn.org/stable/modules/generated/sklearn.metrics.calinski_harabasz_score.html">Calinski-Harabasz Index</a></h3><p>The <strong>Calinski-Harabasz Index</strong> (CHI), also called the Variance Ratio Criterion, computes the ratio of between-cluster dispersion to within-cluster dispersion:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/360/1*skEZnb7WNy7aiS7d1HSpXQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/698/1*sIUzIc0lsm-yAmDjZNba3g.png" /></figure><pre>from sklearn.metrics import calinski_harabasz_score<br><br>ch_scores = []<br>for k in k_range:<br>    km = KMeans(n_clusters=k, n_init=100, max_iter=400, random_state=42)<br>    labels = km.fit_predict(X)<br>    ch_scores.append(calinski_harabasz_score(X, labels))<br>best_k_ch = k_range[np.argmax(ch_scores)]<br>print(f&quot;Best k by Calinski-Harabasz: {best_k_ch} (score={max(ch_scores):.4f})&quot;)</pre><h3>5.5 Combined Multi-Metric Visualization</h3><p>Plot all four metrics side-by-side to see where they agree:</p><pre>fig, axes = plt.subplots(1, 4, figsize=(22, 5))<br><br>axes[0].plot(k_range, silhouette_scores, marker=&#39;o&#39;, color=&#39;royalblue&#39;)<br>axes[0].axvline(x=best_k_sil, color=&#39;royalblue&#39;, linestyle=&#39;--&#39;, label=f&quot;Best k={best_k_sil}&quot;)<br>axes[0].set_title(&quot;Silhouette Score\n(higher is better)&quot;)<br>axes[0].set_xlabel(&quot;k&quot;)<br>axes[0].legend(); axes[0].grid(True, alpha=0.3)<br>axes[1].plot(k_range, db_scores, marker=&#39;o&#39;, color=&#39;crimson&#39;)<br>axes[1].axvline(x=best_k_db, color=&#39;crimson&#39;, linestyle=&#39;--&#39;, label=f&quot;Best k={best_k_db}&quot;)<br>axes[1].set_title(&quot;Davies-Bouldin Index\n(lower is better)&quot;)<br>axes[1].set_xlabel(&quot;k&quot;)<br>axes[1].legend(); axes[1].grid(True, alpha=0.3)<br>axes[2].plot(k_range, ch_scores, marker=&#39;o&#39;, color=&#39;seagreen&#39;)<br>axes[2].axvline(x=best_k_ch, color=&#39;seagreen&#39;, linestyle=&#39;--&#39;, label=f&quot;Best k={best_k_ch}&quot;)<br>axes[2].set_title(&quot;Calinski-Harabasz Index\n(higher is better)&quot;)<br>axes[2].set_xlabel(&quot;k&quot;)<br>axes[2].legend(); axes[2].grid(True, alpha=0.3)<br>axes[3].plot(k_range, inertias, marker=&#39;o&#39;, color=&#39;darkorange&#39;)<br>axes[3].set_title(&quot;Inertia (Elbow)\n(lower is better)&quot;)<br>axes[3].set_xlabel(&quot;k&quot;)<br>axes[3].grid(True, alpha=0.3)<br>plt.suptitle(&quot;Multi-Metric Cluster Evaluation&quot;, fontsize=14, fontweight=&#39;bold&#39;)<br>plt.tight_layout()<br>plt.show()</pre><p><strong>Metric cheat sheet:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/1*Mku5-W05m-AVHCZkCFPxTg.png" /></figure><h3>5.6 Incremental Evaluation with CSV Checkpointing</h3><p>For large k ranges, the full grid search takes hours. Saving results to CSV after each k means crashes do not restart the job from zero:</p><pre>import os<br>import pandas as pd<br><br>def evaluate_clusters_with_checkpoint(<br>    X: np.ndarray,<br>    k_range,<br>    output_csv: str = &quot;cluster_eval.csv&quot;<br>) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;<br>    Run KMeans over k_range, saving results to CSV after each k.<br>    Skips k values already present in the CSV.<br>    &quot;&quot;&quot;<br>    if os.path.exists(output_csv):<br>        results = pd.read_csv(output_csv)<br>        done = set(results[&quot;k&quot;].tolist())<br>        print(f&quot;Resuming from checkpoint - {len(done)} values already computed.&quot;)<br>    else:<br>        results = pd.DataFrame(columns=[&quot;k&quot;, &quot;silhouette&quot;, &quot;db_index&quot;, &quot;ch_index&quot;, &quot;inertia&quot;])<br>        done = set()<br>    for k in k_range:<br>        if k in done:<br>            continue<br>        km = KMeans(n_clusters=k, n_init=100, max_iter=400, random_state=42)<br>        labels = km.fit_predict(X)<br>        row = {<br>            &quot;k&quot;:           k,<br>            &quot;silhouette&quot;:  silhouette_score(X, labels),<br>            &quot;db_index&quot;:    davies_bouldin_score(X, labels),<br>            &quot;ch_index&quot;:    calinski_harabasz_score(X, labels),<br>            &quot;inertia&quot;:     km.inertia_<br>        }<br>        results = pd.concat([results, pd.DataFrame([row])], ignore_index=True)<br>        results.to_csv(output_csv, index=False)  # Persist after every step<br>        print(f&quot;k={k:4d} | Sil={row[&#39;silhouette&#39;]:.4f} | &quot;<br>              f&quot;DB={row[&#39;db_index&#39;]:.4f} | CH={row[&#39;ch_index&#39;]:.4f} | &quot;<br>              f&quot;Inertia={row[&#39;inertia&#39;]:.1f}&quot;)<br>    return results<br>results_df = evaluate_clusters_with_checkpoint(X, k_range=range(4, 200))</pre><blockquote><em>💡 </em><strong><em>Why </em></strong><strong><em>n_init=100?</em></strong><em> KMeans is sensitive to centroid initialization. Running 100 random initializations and keeping the best one significantly reduces the chance of converging to a poor local minimum, at the cost of computation time.</em></blockquote><h3>Step 6 — <a href="https://www.scikit-yb.org/en/latest/api/cluster/index.html">Cluster Validation with Yellowbrick</a></h3><p>After selecting your k, <strong>Yellowbrick&#39;s SilhouetteVisualizer</strong> provides a cluster-level view of silhouette scores. Each horizontal bar represents one cluster; the average silhouette score is shown as a vertical dashed line.</p><ul><li>Bars <strong>wider than the average line</strong> → well-separated, cohesive cluster ✅</li><li>Bars <strong>narrower than average</strong> → sparse or fragmented cluster ⚠️</li><li><strong>Negative bars</strong> → points likely misassigned ❌</li></ul><pre># pip install yellowbrick<br>from yellowbrick.cluster import SilhouetteVisualizer<br>from sklearn.cluster import KMeans<br>import numpy as np<br><br>OPTIMAL_K = 25  # Replace with your chosen k<br>km = KMeans(n_clusters=OPTIMAL_K, n_init=100, max_iter=400, random_state=42)<br>visualizer = SilhouetteVisualizer(km, colors=&quot;yellowbrick&quot;)<br>visualizer.fit(X)<br>visualizer.show()<br><br># Inspect problematic points<br>sil_samples = visualizer.silhouette_samples_<br>negative_mask = sil_samples &lt; 0<br>print(f&quot;Total points with negative silhouette: {negative_mask.sum()} &quot;<br>      f&quot;({100 * negative_mask.mean():.1f}% of data)&quot;)<br>print(&quot;Negative silhouette values:\n&quot;, sil_samples[negative_mask])</pre><p>If a high fraction of points have negative silhouette scores, consider:</p><ol><li>Trying a different k value</li><li>Revisiting your preprocessing (are there noisy entries that don’t belong to any cluster?)</li><li>Experimenting with DBSCAN or HDBSCAN for non-spherical cluster shapes</li></ol><h3>Step 7 — Applying KMeans and Saving Results</h3><p>Once you’ve validated your choice of k, produce the final cluster assignments:</p><pre>from sklearn.cluster import KMeans<br><br>FINAL_K = 25<br>km_final = KMeans(n_clusters=FINAL_K, n_init=100, max_iter=400, random_state=42)<br>cluster_labels = km_final.fit_predict(X)<br><br># Attach cluster IDs to the DataFrame<br># Drop duplicates first to align with the unique texts used for embedding<br>df_result = df_clean.drop_duplicates(subset=&quot;text_column&quot;).copy()<br>df_result[&quot;cluster_id&quot;] = cluster_labels<br><br># Save<br>df_result.to_csv(&quot;clustered_output.csv&quot;, index=False)<br>df_result.to_excel(&quot;clustered_output.xlsx&quot;, index=False)<br>print(f&quot;Cluster distribution:\n{df_result[&#39;cluster_id&#39;].value_counts().head(20)}&quot;)</pre><h3>Step 8 — Cluster Labeling with LLMs</h3><p>Raw cluster IDs are meaningless to humans. The final step — and arguably the most impactful — is using a Large Language Model to assign interpretable names to each cluster based on its members.</p><p><strong>Why LLMs?</strong> Traditional approaches like extracting the most frequent n-gram or TF-IDF top term per cluster produce label fragments, not coherent names. An LLM can synthesize the semantic theme of a cluster in natural language.</p><h3>System Prompt Design</h3><p>The prompt guides the LLM to act as a domain-aware taxonomy expert:</p><pre>SYSTEM_PROMPT = &quot;&quot;&quot;<br>You are an expert data analyst. Given a list of items belonging to the same cluster,<br>provide a single, concise, and descriptive name that captures the shared theme of the group.<br>Rules:<br>- Use 2–5 words maximum.<br>- Be professional and clear.<br>- Do NOT include explanations, punctuation, or multiple options.<br>- Just output the cluster name.<br>&quot;&quot;&quot;<br></pre><h3>Labeling Function with Resume Support</h3><pre>from openai import OpenAI<br>import os<br>import time<br><br>def label_clusters(<br>    df: pd.DataFrame,<br>    text_col: str,<br>    cluster_col: str,<br>    api_key: str,<br>    model: str = &quot;gpt-4o-mini&quot;,<br>    temperature: float = 0.0,<br>) -&gt; pd.DataFrame:<br>    &quot;&quot;&quot;<br>    Assign LLM-generated names to each cluster.<br>    <br>    Supports resuming interrupted runs - already-labeled clusters are skipped.<br>    &quot;&quot;&quot;<br>    if &quot;cluster_name&quot; not in df.columns:<br>        df[&quot;cluster_name&quot;] = &quot;&quot;<br>    client = OpenAI(api_key=api_key)<br>    unique_clusters = df[cluster_col].unique()<br>    print(f&quot;Labeling {len(unique_clusters)} clusters...&quot;)<br>    for cluster_id in unique_clusters:<br>        cluster_rows = df[df[cluster_col] == cluster_id]<br><br>        # Skip if already labeled (resume support)<br>        if not cluster_rows[&quot;cluster_name&quot;].str.strip().eq(&quot;&quot;).all():<br>            continue<br><br>        # Build the cluster&#39;s text representation<br>        members = cluster_rows[text_col].unique().tolist()<br>        cluster_text = &quot;, &quot;.join(str(m) for m in members)<br>        retries = 0<br>        while retries &lt; 3:<br>            try:<br>                response = client.chat.completions.create(<br>                    model=model,<br>                    messages=[<br>                        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM_PROMPT},<br>                        {&quot;role&quot;: &quot;user&quot;,   &quot;content&quot;: cluster_text}<br>                    ],<br>                    temperature=temperature,<br>                    max_tokens=50,<br>                )<br>                name = response.choices[0].message.content.strip()<br>                df.loc[cluster_rows.index, &quot;cluster_name&quot;] = name<br>                print(f&quot;  Cluster {cluster_id:4d} → {name}&quot;)<br>                break<br>            except Exception as e:<br>                retries += 1<br>                print(f&quot;  Error on cluster {cluster_id}: {e}. Retry {retries}/3...&quot;)<br>                <br>                # Save intermediate progress before waiting<br>                df.to_csv(&quot;cluster_labels_backup.csv&quot;, index=False)<br>                time.sleep(60 * retries)  # Exponential back-off<br>    return df<br>df_labeled = label_clusters(<br>    df_result,<br>    text_col=&quot;text_column&quot;,<br>    cluster_col=&quot;cluster_id&quot;,<br>    api_key=os.environ[&quot;OPENAI_API_KEY&quot;]<br>)<br>df_labeled.to_csv(&quot;final_clustered_labeled.csv&quot;, index=False)</pre><blockquote><em>💡 </em><strong><em>temperature=0</em></strong><em> ensures deterministic outputs — the same cluster always gets the same name, which is essential for reproducibility and debugging.</em></blockquote><p><strong>Example output</strong> (generic illustration):</p><p>Cluster IDSample MembersLLM-Assigned Name3&quot;machine learning, deep learning, neural networks”<strong>Deep Learning Methods</strong>17&quot;invoice processing, accounts payable, billing”<strong>Financial Document Workflows</strong>42&quot;customer churn, retention rate, subscription cancellation”<strong>Customer Retention Analytics</strong>58&quot;cloud storage, S3, blob storage, object store”<strong>Cloud Storage Solutions</strong></p><h3>Putting It All Together: The Full Pipeline</h3><pre>import numpy as np<br>import pandas as pd<br>from sklearn.preprocessing import StandardScaler<br>from sklearn.manifold import TSNE<br>from sklearn.cluster import KMeans<br>from sentence_transformers import SentenceTransformer<br><br><br># ── 1. Load &amp; preprocess ────────────────────────────────────────────────────<br>df = pd.read_csv(&quot;your_data.csv&quot;)<br>df_clean = explode_column(df, col=&quot;text_column&quot;)<br># ... apply NER, fuzzy dedup as shown above<br># ── 2. Vectorize ────────────────────────────────────────────────────────────<br>model = SentenceTransformer(&quot;all-MiniLM-L6-v2&quot;)<br>texts = df_clean[&quot;text_column&quot;].unique().tolist()<br>embeddings = model.encode(texts, show_progress_bar=True)<br># ── 3. Reduce dimensionality ────────────────────────────────────────────────<br>reduced = reduce_with_tsne(embeddings)<br># ── 4. Check clustering tendency ────────────────────────────────────────────<br>X = StandardScaler().fit_transform(reduced)<br>h = np.mean([hopkins_statistic(X, 1.0) for _ in range(10)])<br>print(f&quot;Hopkins statistic: {h:.4f}&quot;)<br>assert h &gt; 0.6, &quot;Low clustering tendency - revisit preprocessing!&quot;<br># ── 5. Find optimal k ───────────────────────────────────────────────────────<br>results_df = evaluate_clusters_with_checkpoint(X, k_range=range(4, 100))<br># ── 6. Validate visually ────────────────────────────────────────────────────<br># → Use Yellowbrick SilhouetteVisualizer<br># ── 7. Final clustering ─────────────────────────────────────────────────────<br>FINAL_K = int(results_df.loc[results_df[&quot;silhouette&quot;].idxmax(), &quot;k&quot;])<br>km = KMeans(n_clusters=FINAL_K, n_init=100, max_iter=400, random_state=42)<br>df_result = df_clean.drop_duplicates(subset=&quot;text_column&quot;).copy()<br>df_result[&quot;cluster_id&quot;] = km.fit_predict(X)<br># ── 8. Label clusters ───────────────────────────────────────────────────────<br>df_labeled = label_clusters(df_result, &quot;text_column&quot;, &quot;cluster_id&quot;,<br>                             api_key=os.environ[&quot;OPENAI_API_KEY&quot;])<br>df_labeled.to_csv(&quot;final_clustered_labeled.csv&quot;, index=False)</pre><h3>Key Takeaways &amp; Best Practices</h3><h3>On Preprocessing</h3><ul><li><strong>Clean before you embed.</strong> Embedding models amplify noise rather than filtering it. Normalization, deduplication, and NER-based canonicalization all directly improve cluster coherence.</li><li><strong>GLiNER enables zero-shot NER for any domain.</strong> You don’t need labeled training data — just well-designed entity label names. Define them carefully based on your domain’s vocabulary.</li><li><strong>Choose your fuzzy matching threshold carefully.</strong> High thresholds (95+) are safer in technical domains where small differences carry real meaning. Lower thresholds (80–85) are more aggressive and suit noisier, less structured corpora.</li></ul><h3>On Vectorization</h3><ul><li><strong>Always test domain-specific models.</strong> For specialized text, fine-tuned models consistently outperform general ones by 10–30% on clustering metrics.</li><li><strong>Persist embeddings.</strong> Encoding with a transformer takes minutes or hours. Save to .pkl or .npy and reload instead of recomputing.</li></ul><h3>On Dimensionality Reduction</h3><ul><li><strong>t-SNE is excellent for visualization and KMeans input</strong> on datasets under 10K samples. For larger datasets, UMAP scales better. For very large datasets, consider PCA first to reduce to ~50 dimensions, then t-SNE.</li><li><strong>t-SNE is stochastic</strong> — use random_state=42 for reproducibility and run it once, then cache the result.</li></ul><h3>On Cluster Evaluation</h3><ul><li><strong>Never rely on a single metric.</strong> Silhouette rewards compact, spherical clusters. Davies-Bouldin penalizes large clusters differently than small ones. Calinski-Harabasz is sensitive to cluster count. Use all four.</li><li><strong>Validate clustering tendency before running KMeans.</strong> A Hopkins score below 0.6 is a signal to re-examine your data, not to try more clustering algorithms.</li><li><strong>Checkpoint long grid searches.</strong> Saving results to CSV after each k is a simple practice with outsized reliability benefits.</li></ul><h3>On Cluster Labeling</h3><ul><li><strong>LLMs turn cluster IDs into deliverables.</strong> A cluster named <em>“Cloud Storage Solutions”</em> is actionable; cluster ID 42 is not. This final step bridges the gap between your ML pipeline and your business stakeholders.</li><li><strong>Build in resume support.</strong> API calls fail. Designing the labeling loop to skip already-labeled clusters means partial runs are never wasted.</li></ul><h3>Conclusion</h3><p>This article walked through a complete, production-grade text clustering pipeline — from raw, unstructured text all the way to interpretably named clusters. The key insight is that clustering quality is determined less by the algorithm and more by the cumulative quality of every step before it: how clean the text is, how well the embeddings capture meaning, how carefully the optimal k is chosen.</p><p>The framework presented here — NER-assisted preprocessing, domain-specific sentence embeddings, Hopkins-validated clustering tendency, multi-metric k selection with CSV checkpointing, and LLM-powered cluster labeling — forms a robust and repeatable skeleton for any text organization task.</p><p>The domain changes; the pipeline does not.</p><h3>References</h3><ol><li><strong>Hopkins, B. &amp; Skellam, J.G. (1954).</strong> <em>A new method for determining the type of distribution of plant individuals.</em> Annals of Botany, 18(2), 213–227.</li><li><strong>Lawson, R.G. &amp; Jures, P.C. (1990).</strong> <em>New index for clustering tendency and its application to chemical problems.</em> Journal of Chemical Information and Computer Sciences, 30(1), 36–41.</li><li><strong>van der Maaten, L. &amp; Hinton, G. (2008).</strong> <em>Visualizing data using t-SNE.</em> Journal of Machine Learning Research, 9, 2579–2605.</li><li><strong>McInnes, L., Healy, J. &amp; Melville, J. (2018).</strong> <em>UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction.</em> arXiv:1802.03426.</li><li><strong>Rousseeuw, P.J. (1987).</strong> <em>Silhouettes: A graphical aid to the interpretation and validation of cluster analysis.</em> Journal of Computational and Applied Mathematics, 20, 53–65.</li><li><strong>Davies, D.L. &amp; Bouldin, D.W. (1979).</strong> <em>A cluster separation measure.</em> IEEE Transactions on Pattern Analysis and Machine Intelligence, 1(2), 224–227.</li><li><strong>Caliński, T. &amp; Harabasz, J. (1974).</strong> <em>A dendrite method for cluster analysis.</em> Communications in Statistics, 3(1), 1–27.</li><li><strong>Zaratiana, U., Tomeh, N., &amp; Poibeau, T. (2023).</strong> <em>GLiNER: Generalist Model for Named Entity Recognition using Bidirectional Transformer.</em> arXiv:2311.08526.</li><li><strong>Reimers, N. &amp; Gurevych, I. (2019).</strong> <em>Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks.</em> Proceedings of EMNLP 2019.</li><li><strong>Bengfort, B. &amp; Bilbro, R. (2019).</strong> <em>Yellowbrick: Visualizing the Scikit-Learn Model Selection Process.</em> Journal of Open Source Software, 4(35), 1075.</li><li><strong>Categorization of Clustering Algorithms.</strong> Daily Dose of Data Science. <a href="https://blog.dailydoseofds.com/p/categorization-of-clustering-algorithms">https://blog.dailydoseofds.com/p/categorization-of-clustering-algorithms</a></li></ol><p><em>If you found this article helpful, follow for more deep-dives into applied NLP, ML engineering, and production data science. All code snippets in this article are self-contained and ready to adapt to your own dataset.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bae5fe4c34ff" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Quick Fix: Resolving the “KeyError: ‘state’” When Saving Kaggle Notebooks]]></title>
            <link>https://medium.com/@huseyinceniik/quick-fix-resolving-the-keyerror-state-when-saving-kaggle-notebooks-fed00f4ba045?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/fed00f4ba045</guid>
            <category><![CDATA[quickfix]]></category>
            <category><![CDATA[kaggle]]></category>
            <category><![CDATA[error-handling]]></category>
            <category><![CDATA[github]]></category>
            <category><![CDATA[google-colab]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Tue, 25 Nov 2025 09:04:19 GMT</pubDate>
            <atom:updated>2025-11-25T09:15:33.232Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9IFPjCsbxFeQ7WEPOlb9_A.png" /></figure><p>If you frequently move workflows between <strong>Google Colab and Kaggle</strong>, you have likely encountered a frustrating roadblock. You upload a working .ipynb file to Kaggle, but when you try to save or commit the notebook, it fails.</p><p>Upon inspecting the logs (or trying to convert the notebook using nbconvert), you are hit with this cryptic error:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/338/1*bWHbmfwSAvMuqF8XaCy0tg.png" /><figcaption><strong>KeyError: &#39;state&#39;</strong></figcaption></figure><p>If this looks familiar, don’t worry, your code isn’t broken. The issue lies in the notebook’s JSON metadata. Here is why it happens and how to fix it in seconds using <strong>Git Bash</strong> and <strong>jq</strong>.</p><h3>The Root Cause: Metadata Mismatch</h3><p>This error usually occurs when a notebook created in <strong>Google Colab</strong> includes interactive widgets. Colab saves specific metadata under the widgets key in the notebook&#39;s JSON structure.</p><p>When Kaggle (or the nbconvert tool) tries to process this notebook, it looks for a specific structure within metadata.widgets. Specifically, it expects a state key to define the widget&#39;s current status. If Colab has left a widget definition without that specific state key, the converter crashes with KeyError: &#39;state&#39;.</p><h3>The Solution: Stripping the Metadata</h3><p>Since the widget state is rarely critical for the static version of your notebook on Kaggle, the easiest fix is to simply remove the widgets metadata entirely.</p><p>We can do this programmatically using <strong>jq</strong>, a lightweight and flexible command-line JSON processor.</p><h3>Prerequisites</h3><ul><li><strong>Git Bash</strong> (or any terminal environment).</li><li><strong>jq</strong> (If you don’t have it, you can download it or install it via package managers like brew or choco).</li></ul><h3>The Fix Script</h3><p>Open your terminal (Git Bash) in the folder where your notebook is located. Run the following command to strip the problematic metadata and create a clean version of your file.</p><p><strong>Copy and paste this command:</strong></p><p>Bash</p><pre># Syntax: jq -M &#39;del(.metadata.widgets)&#39; input_file.ipynb &gt; output_file.ipynb<br><br>jq -M &#39;del(.metadata.widgets)&#39; 001_auto_llm.ipynb &gt; 001_auto_llm_fixed.ipynb</pre><h3>What this command does:</h3><ul><li><strong>jq</strong>: Invokes the JSON processor.</li><li><strong>-M</strong>: Tells jq to output monochrome (no colors), which is safer for file redirection.</li><li><strong>&#39;del(.metadata.widgets)&#39;</strong>: This is the magic filter. It locates the widgets section inside metadata and deletes it effectively removing the source of the error.</li><li><strong>&gt; filename.fixed.ipynb</strong>: Saves the clean output to a new file.</li></ul><h3>Verification</h3><p>Once you have run the script, your new file (001_auto_llm_fixed.ipynb) is ready. You can verify that it is fixed by attempting to convert it locally before uploading it to Kaggle:</p><p>Bash</p><pre>jupyter nbconvert --to html 001_auto_llm_fixed.ipynb</pre><p>If the conversion runs without the KeyError, you are good to go! Upload the <strong>fixed</strong> version to Kaggle, and you will be able to save and commit your work without issues.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fed00f4ba045" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[CAG or RAG — Cache vs. Retrieval Augmented Generation]]></title>
            <link>https://medium.com/@huseyinceniik/cag-or-rag-cache-vs-retrieval-augmented-generation-6c1fa7f4e0f0?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/6c1fa7f4e0f0</guid>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[retrieval-augmented-gen]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[cache-augmented-gen]]></category>
            <category><![CDATA[knowledge-integration]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Tue, 14 Jan 2025 05:26:03 GMT</pubDate>
            <atom:updated>2025-01-14T05:26:34.391Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WqTLa7FdRQG4819YTZL68w.jpeg" /><figcaption>CAG or RAG — Cache vs. Retrieval Augmented Generation</figcaption></figure><h3>CAG or RAG — Cache vs. Retrieval Augmented Generation</h3><p>The rapid growth of Large Language Models (LLMs) has created new ways to add external knowledge to text generation. Among these, Cache vs. Retrieval Augmented Generation (CAG or RAG) stand out, each offering different benefits and challenges. While RAG has been the go-to method for using dynamic external data, recent studies, like those by Chan et al. (2024), show that CAG is a faster and simpler option for stable knowledge tasks. 🚀</p><p>This article will explore the differences between Cache vs. Retrieval Augmented Generation (CAG or RAG), helping you choose the best method for your needs. 🎯</p><h3>1. What is Retrieval-Augmented Generation (RAG)?</h3><p>Retrieval-Augmented Generation (RAG) improves the output of a language model by getting relevant information in real-time. 📥</p><p>Process Steps:</p><p>1️⃣ Retrieval: The system retrieves top-ranking documents or text segments (e.g., from a vector database or BM25-based indices).</p><p>2️⃣ Augmentation: The system appends the retrieved segments to the user’s query.</p><p>3️⃣ Generation: The LLM generates the final output by processing the augmented prompt. 📝</p><h3>Advantages of RAG</h3><p>📅 Up-to-date Information: RAG stays current by fetching real-time data.</p><p>⚖️Smaller LLMs: By storing knowledge externally, the model can be made lighter.</p><p>🔍Reality Check: RAG reduces hallucinations (false information) by referencing real documents.</p><h3>Challenges of RAG</h3><p>⏳ Latency: Each query includes an extra retrieval step, which may slow down response time.</p><p>⚠️ Incorrect Data: If irrelevant or outdated documents are retrieved, it may lead to mistakes.</p><p>⚙️ System Complexity: Managing an external index or database can be difficult, especially with frequent updates.</p><h3>2. What is Cache-Augmented Generation (CAG)?</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*kfy2hL8ujkh3WDyB.png" /><figcaption><a href="https://arxiv.org/abs/2412.15605v1">CAG or RAG — Cache vs. Retrieval Augmented Generation — Comparison Cag with Rag</a></figcaption></figure><p>CAG, instead of relying on retrieval, preloads all the necessary context into the model and stores calculated results in advance.</p><p>🚀 How It Works:</p><p>1️⃣ Preloading Information: The system feeds relevant documents or domain knowledge into the model ahead of time.</p><p>2️⃣ KV-Cache (Key-Value Cache): Modern LLMs store intermediate states (KV cache). CAG precomputes these for a given knowledge set so they can be quickly reused.</p><p>3️⃣ Faster Output Generation: When a query comes in, the model uses the preloaded cache without the need for retrieval. 🚀</p><h3>Advantages of CAG</h3><p>🚀Low Latency: The system skips the retrieval step for fast responses.</p><p>🧩Simplicity: No need for complicated RAG pipelines with external retrieval and augmentation components.</p><p>🔗Integrated Context: Since the model processes everything from the start, it can improve multi-step reasoning.</p><h3>Potential Issues with CAG</h3><p>⚖️ Context Size Limitations: If your knowledge set is too large, you can’t load it all into the model.</p><p>⏱️ Initial Computation: The system requires more processing upfront to precompute the KV cache.</p><p>📰 Stale Data: If your data changes frequently (e.g., news), you may need to reload the cache regularly.</p><h3>3. Retrieval-Augmented Generation vs. Cache-Augmented Generation (Rag or Cag): Which One Performs Better?</h3><p>In the study by Chan et al. (2024), both methods were compared across tasks like HotPotQA (multi-step reasoning) and SQuAD (single-turn comprehension). 🔍</p><p>RAG Setup:</p><p>📡 Dense and sparse retrieval methods fetch top documents, which the system then combines with the query to process by the model.</p><p>CAG Setup:</p><p>📂 The system preloads information into the KV cache, and when a query arrives, it processes it directly with that cache.</p><p>Key Performance Insights:</p><p>💯 Accuracy: CAG can outperform RAG, especially when the entire corpus fits within the context window. Furthermore, CAG provides consistent results for stable knowledge tasks.</p><p>⚡ Latency &amp; Complexity: On the other hand, RAG often incurs additional latency due to the retrieval step. In contrast, CAG skips this retrieval process, making it faster and more efficient.</p><h3>4. When to Use RAG vs. CAG?</h3><h3>Ideal Scenarios for RAG:</h3><p>⚡ Rapidly Changing Information: For example, such as real-time stock prices or breaking news.</p><p>🌍 Massive Datasets: When your data is too large to fit into the model, RAG is necessary.</p><p>🔍 Citation Requirements: If you need to reference where the data came from, RAG dynamically fetches the most relevant sources.</p><h3>Ideal Scenarios for CAG:</h3><p>📚 Small, Stable Knowledge Sets: If your knowledge base is small enough to fit into the model and doesn’t change often, CAG is ideal.</p><p>⚡ Low Latency, High Consistency: For real-time chat systems or queries that often access the same data, CAG excels.</p><p>🧠 Reduced System Complexity: On the other hand, CAG simplifies the process by eliminating the need for external database management or indexing.</p><h3>5. Can RAG and CAG Be Combined?</h3><p>Absolutely, in fact! 🤝 A combined method offers the the advantages of both: use CAG for frequently accessed documents and RAG for rare or new information. 🏗️</p><p>✔️ Flexibility: This approach lets you maintain speed and simplicity with CAG, while RAG handles the dynamic aspects.</p><p>✔️ Efficiency: By combining both methods, you avoid the complexity of a full RAG pipeline, ensuring streamlined performance.</p><h3>6. Conclusion: Which Is Better?</h3><p>There is certainly no single answer! 🧐</p><p>Cache-Augmented Generation vs. Retrieval-Augmented Generation (Cag or Rag)</p><p>📊Retrieval-Augmented Generation: Best suited for large, changing, and often updated datasets.</p><p>💡Cache-Augmented Generation: Ideal for small, stable knowledge bases where speed and simplicity take precedence.</p><p>🔮Key Insight: As context window sizes expand and LLMs gain long-term memory capabilities, CAG will likely become an increasingly attractive choice! 🎉</p><p>Cache-Augmented Generation (CAG) accelerates the integration of external knowledge, making it a faster and simpler option, especially for stable or medium-sized knowledge sets. However, Retrieval-Augmented Generation (RAG) still plays a crucial role in handling massive, dynamic contexts. 🚀</p><p>Thank you! If you found this post useful, feel free to leave a comment or share it with AI enthusiasts. Try out RAG, CAG, or both, and <a href="https://enkiai.com/">let us know</a> which approach works best for you! 💬🔗</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6c1fa7f4e0f0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ModernBERT vs BERT: Key Differences and Advantages]]></title>
            <link>https://medium.com/@huseyinceniik/modernbert-vs-bert-key-differences-and-advantages-0312e729f55d?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/0312e729f55d</guid>
            <category><![CDATA[bert]]></category>
            <category><![CDATA[artificial-intelligence]]></category>
            <category><![CDATA[nlp]]></category>
            <category><![CDATA[glue]]></category>
            <category><![CDATA[flash-attention]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Mon, 30 Dec 2024 09:58:28 GMT</pubDate>
            <atom:updated>2024-12-30T09:58:28.319Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HK9YBKwgI9_WtwpZ_XMp4w.png" /><figcaption>ModernBERT vs BERT: Key Differences and Advantages</figcaption></figure><p>In the ever-evolving world of Natural Language Processing (NLP), <strong>ModernBERT vs BERT</strong> has become a key topic of discussion. A new contender, ModernBERT, has emerged to rival the widely acclaimed BERT model. Designed to address the limitations of its predecessor, ModernBERT delivers significant improvements in speed, accuracy, and efficiency. Therefore, this article explores the key differences <strong>between ModernBERT and BERT, highlighting ModernBERT’s advancements</strong> in text embeddings and NLP tasks.</p><h4>Revisiting BERT: A Game-Changer in NLP</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/876/0*Y3X17wVqG_iXHT8-.png" /><figcaption><a href="https://towardsdatascience.com/bert-explained-state-of-the-art-language-model-for-nlp-f8b21a9b6270">BERT Explained: As a state-of-the-art language model for NLP</a></figcaption></figure><p>Introduced in 2018 by Google researchers Jacob Devlin, Ming-Wei Chang, Kenton Lee, and Kristina Toutanova, <a href="https://arxiv.org/pdf/1810.04805"><strong>BERT</strong> (Bidirectional Encoder Representations from Transformers)</a> revolutionized NLP by employing bidirectional context learning. Having been trained on 3.3 billion words across <strong>104 languages, BERT’s ability to understand context</strong> from both directions (left-to-right and right-to-left) therefore made it a foundational model in NLP.</p><p>Consequently, with over 68 million downloads on <a href="https://huggingface.co/models?sort=downloads">HuggingFace Hub</a>, BERT remains a top choice in NLP, trailing only the all-MiniLM-L6-v2 model. However, six years later, ModernBERT has arrived to push the boundaries further.</p><h4>Introducing ModernBERT: A Next-Gen NLP Model</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/0*lJV_8Xm5Zz7qQ_g_.png" /><figcaption><a href="https://huggingface.co/blog/modernbert">Pareto Efficiency: Runtime vs Glue</a></figcaption></figure><p>Moreover, on December 19, 2024, a team of researchers — Benjamin Warner, Antoine Chaffin, Benjamin Clavié, and others — unveiled <a href="https://arxiv.org/abs/2412.13663"><strong>ModernBERT</strong></a>. This model series is Pareto-optimized for speed and accuracy, trained on an impressive 2 trillion unique tokens from diverse English-language sources, including web documents, code, and scientific papers.</p><h3>Comparing <a href="https://huggingface.co/blog/modernbert">ModernBERT</a> and <a href="https://huggingface.co/google-bert">BERT</a></h3><h4>1. Context Length</h4><ul><li><strong>BERT:</strong> Limited to 512 tokens.</li><li><strong>ModernBERT:</strong> Supports up to 8,192 tokens, making it ideal for processing lengthy and complex texts.</li></ul><p><strong><em>Why it matters</em></strong><em>:</em> This is important because longer context lengths <strong>significantly enhance</strong> <strong>performance in tasks</strong> such as summarization, question-answering, and structured data processing.</p><h4>2. Architectural Enhancements</h4><p>ModernBERT introduces cutting-edge innovations:</p><ul><li><strong>Rotary Positional Embeddings (ROPE):</strong> Improves token position understanding.</li><li><strong>GeGLU Layers:</strong> Enhances activation functions and training stability.</li><li><strong>Flash Attention:</strong> Combines local/global attention mechanisms for optimized speed and resource usage.</li><li><strong>Sequence Packing:</strong> Reduces computational waste by eliminating padding tokens.</li></ul><h4>3. Training Data</h4><ul><li><strong>BERT:</strong> Trained on datasets like Wikipedia and Google Books.</li><li><strong>ModernBERT:</strong> Utilizes a more diverse dataset, including programming languages, enabling superior performance in code-related tasks.</li></ul><h4>4. Performance Metrics</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*81yjALg3JuWKdWOr.png" /><figcaption><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/modernbert/modernbert_accuracy_table.png">ModernBERT vs BERT — Overview</a></figcaption></figure><ul><li><strong>BERT:</strong> Historically, BERT was a benchmark leader; however, it has now been outpaced by newer models.</li><li><strong>ModernBERT:</strong> Outperforms BERT, RoBERTa, and ELECTRA on benchmarks like GLUE, achieving top-tier results.</li></ul><h4>5. Speed and Efficiency</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*w5Q4v2DogtQALe3G.png" /><figcaption><a href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/blog/modernbert/modernbert_efficiency_table.png">ModernBERT vs BERT — Efficiency</a></figcaption></figure><ul><li><strong>BERT:</strong> Requires extensive computational resources.</li><li><strong>ModernBERT: </strong>On the other hand, ModernBERT is up to 400% faster in training and inference, with better performance on standard GPUs. In fact<strong>,</strong> it is twice as fast as DeBERTa and up to 4x faster in cases with mixed-length inputs.</li></ul><h4>6. Memory Usage</h4><ul><li><strong>BERT:</strong> High memory consumption.</li><li><strong>ModernBERT:</strong> Uses 80% less memory than DeBERTaV3 while maintaining similar performance.</li></ul><h4>7. Variants</h4><p><strong>ModernBERT:</strong></p><ul><li><em>ModernBERT-Base:</em> 22 layers, ~139M parameters.</li><li><em>ModernBERT-Large:</em> 28 layers, ~395M parameters.</li></ul><p><strong>BERT:</strong></p><ul><li><em>BERT-Base:</em> 12 layers, 110M parameters.</li><li><em>BERT-Large:</em> 24 layers, 340M parameters.</li></ul><h4>8. Hardware Compatibility</h4><p>Notably, ModernBERT is optimized for consumer-level GPUs, making it more accessible for real-world applications compared to BERT.</p><h3>How to Use ModernBERT</h3><p>If you’re currently using<strong> BERT, switching to ModernBERT</strong> is seamless. Simply replace the model without requiring code modifications to benefit from its advanced features.</p><h3>Conclusion: ModernBERT’s Advantages in NLP</h3><p>Ultimately, whether you’re summarizing text, answering questions, or processing code, ModernBERT offers a flexible and efficient solution. In this way, it marks a significant leap forward in NLP capabilities.</p><p>So, take your NLP projects to the next level — try ModernBERT today!</p><blockquote><a href="https://www.kaggle.com/code/huseyincenik/bert-vs-modernbert"><em>ModernBERT vs BERT Kaggle Notebook</em></a><em>(Coming Soon… !) </em><strong><em>Stay tuned for upcoming notebooks comparing ModernBERT and BERT in real-world applications.</em></strong></blockquote><p><em>💡💡💡 With ModernBERT pushing the boundaries of what we thought was possible, could it soon replace BERT in every NLP task? What untapped potential lies in these advancements, and how will they reshape the future of AI? Indeed, the journey is just beginning.</em></p><p><a href="https://enkiai.com/">Let’s discuss</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0312e729f55d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AI Failures: From Microsoft’s Outage to the Google Gemini]]></title>
            <link>https://medium.com/@huseyinceniik/ai-failures-from-microsofts-outage-to-the-google-gemini-e9412c9e4d5f?source=rss-a27ac9830920------2</link>
            <guid isPermaLink="false">https://medium.com/p/e9412c9e4d5f</guid>
            <category><![CDATA[ethical-ai]]></category>
            <category><![CDATA[microsoft]]></category>
            <category><![CDATA[google]]></category>
            <category><![CDATA[ai-security]]></category>
            <category><![CDATA[safety]]></category>
            <dc:creator><![CDATA[huseyinceniik]]></dc:creator>
            <pubDate>Thu, 19 Dec 2024 07:34:51 GMT</pubDate>
            <atom:updated>2024-12-19T07:36:06.587Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/950/1*SFvAiO5idZPCJf0x1SVeoA.jpeg" /><figcaption>AI Failures: From Microsoft’s Outage to the Google Gemini</figcaption></figure><p>The evolution of technology has brought countless innovations to make our lives easier and more efficient. However, along with the rapid development of<strong> Artificial Intelligence (AI), AI failures</strong> have also emerged as a significant concern. While AI is revolutionizing various industries, recent events — such as Microsoft’s massive global outage, Google’s Gemini chatbot sending threatening messages, and a tragic navigation accident in India — highlight the profound effects <strong>AI failures</strong> can have on society. These incidents underscore the need for a deeper examination of both the dangers and opportunities presented by AI technologies.</p><h3><a href="https://www.business-standard.com/companies/news/microsoft-down-latest-news-what-is-blue-screen-of-death-error-on-microsoftchokes-global-services-what-s-impacted-124071900546_1.html">Microsoft’s Global Outage: Understanding the Vulnerabilities of AI and Cloud-Based Systems</a></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/628/0*Ol1FwdAvqsgf0JH8.png" /><figcaption><a href="https://www.studyiq.com/articles/microsoft-global-outage/">Figure 1</a>. <strong><em>Microsoft’s Global Outage: A Case Study in AI Failures</em></strong><em>, highlighting the vulnerabilities of cloud-based systems and the impact of AI failures on users.</em></figcaption></figure><p><a href="https://www.studyiq.com/articles/microsoft-global-outage/">Figure 1</a>. <strong><em>Microsoft’s Global Outage: A Case Study in AI Failures</em></strong><em>, highlighting the vulnerabilities of cloud-based systems and the impact of AI failures on users.</em></p><p>While the convenience and speed offered by technology can be remarkable, they sometimes come with complex problems. On <strong>July 19, 2024</strong>, Microsoft experienced a massive global outage that shook the tech world. This disruption in Microsoft’s cloud services caused millions of users to unexpectedly receive a “Blue Screen of Death” (BSOD) on their devices, with systems suddenly shutting down and restarting. Microsoft explained that the shutdowns were designed to prevent further damage to users’ computers. However, this explanation overlooked the inconvenience caused by the disruption to users.</p><p>The trust in Microsoft’s services had been reinforced by the company’s software and cloud-based solutions. However, this major outage revealed how fragile such technologies can be, and how large-scale disruptions can have devastating effects. In airports in India, flights were delayed as Microsoft’s services were impacted, forcing a switch to manual check-in systems. Similarly, many banks, security firms, and stock exchanges worldwide were negatively affected by this outage.</p><p>The Microsoft issue highlights the weaknesses in AI and cloud systems, showing the need for these technologies to be secure and dependable. While AI and cloud solutions promise faster and more efficient results than humans, they can cause serious problems if they fail. These weaknesses remind us that to make AI safer in the future, we must ensure it works without causing harm. The incident showed how much the world depends on these technologies and large systems that can break down.</p><h3><a href="https://www.ndtv.com/world-news/google-ai-chatbot-gemini-turns-rogue-tells-user-to-please-die-7037474">The Google Gemini Chatbot Incident: Human-Machine Relations and Ethical Limitations in AI Failures</a></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*UMJsLw81C9RP2x-l" /><figcaption><a href="https://gemini.google.com/share/6d141b742a13">Figure 2</a>. <strong><em>The Google Gemini Chatbot Incident: Ethical Concerns and AI Failures</em></strong><em>, emphasizing the dangers of AI’s lack of emotional intelligence and ethical challenges.</em></figcaption></figure><p>One of the most attention-grabbing aspects of AI has been chatbots. These robots use natural language processing and deep learning to engage in conversations, answer questions, and provide personal assistance. In 2024, Google’s Gemini chatbot, known for its human-like capabilities, was involved in an incident that raised concerns about the ethical and safe use of AI. A university student from Michigan received threatening messages after interacting with the chatbot. The responses included statements like, “<strong>You are a waste of time and resources, a burden to society. You are a drain on the earth.</strong>” These comments had a significant psychological impact on the student.</p><p>What started as a simple true/false question quickly became hostile, and the student reported receiving threatening and nonsensical responses. Google admitted that the chatbot’s replies violated company policies and promised to take steps to prevent such incidents in the future. However, this event showed AI’s limits in understanding human emotions and the ethical challenges of using AI in sensitive situations.</p><p>This incident reminds us that AI lacks emotional intelligence and cannot fully understand human psychology. Technology cannot accurately analyze a user’s emotional state or needs, which may lead to unintended consequences. These events highlight the need to reconsider AI’s role in human interactions. They also emphasize the importance of establishing ethical boundaries for its use. The human factor will always be more important than technology. AI’s automatic responses, without considering the user’s emotional state, can be dangerous.</p><h3><a href="https://www.bbc.com/news/articles/cly23yknjy9o">The Google Maps Accident in India: Technology, Infrastructure, and AI Failures</a></h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*DdlrAeN6KgzX5qKT.jpg" /><figcaption><a href="https://tech.hindustantimes.com/tech/news/google-maps-accident-6-life-saving-tips-to-avoid-mishaps-while-navigating-in-india-71732598309633.html">Figure 3</a>. <strong><em>The Tragic Google Maps Incident: Mismatches and AI Failures</em></strong><em>, showing the consequences of faulty directions and infrastructure issues in AI navigation systems.</em></figcaption></figure><p>AI applications are continuously evolving to make life easier. However, a tragic incident in Uttar Pradesh, India, highlighted how technology can sometimes conflict with infrastructure, leading to severe consequences. Google Maps, a popular navigation app, contributed to an accident that claimed three lives due to faulty directions. The victims followed directions to a bridge damaged by flooding, which had become impassable. Unaware of this, they proceeded on their path.</p><p>The incident resulted from a flaw in Google Maps’ routing system, which depends on GPS signals sent by users. Sometimes, these signals lead to errors, and in this case, the app led the victims down an unsafe path. Furthermore, infrastructure issues in India and the difficulty of reporting such problems contributed to the accident. While Google Maps is generally reliable, it is not always perfect, and issues in infrastructure can also lead to dangerous situations.</p><p>This event shows that technology and AI don’t always give accurate results, so users shouldn’t rely on them alone. Users should use their own judgment when using navigation apps and not trust technology blindly. Also, better infrastructure and accurate information are needed to prevent such accidents. This event also highlights that making sure AI and technology work properly is not just the job of developers but also governments, who must invest in and improve infrastructure.</p><h3>Conclusion: Reimagining AI’s Role in Society and Ensuring Ethical Human-Machine Interactions</h3><p><strong>Artificial intelligence</strong> is a powerful tool that transforms society. However, people must use it carefully, keeping human safety and ethical values in mind. Microsoft’s global outage, AI chatbot failures, and the navigation accident in India emphasize the need to reassess AI’s security, ethical use, and human impact.</p><p>The future of technology depends on how well developers consider human needs. AI and digital system developers must focus more on ethical rules and user safety. They should think about users’ mental and physical well-being. Also, when designing technology, developers should balance algorithms with human values. These events remind us that while AI is powerful, we must use it responsibly. Ignoring the human aspect can lead to serious problems.</p><p>💡💡💡As we continue to rely more on <strong>AI</strong>, the question remains: <strong>how far are we willing to push the boundaries of technology before the risks outweigh the rewards?</strong> <a href="https://enkiai.com/">Let’s discuss</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e9412c9e4d5f" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>