Anthony Kurieh /* start here */ Get in touch
Dubai, UAE · Choreograph, WPP Data, Analytics & AI

Data that survives contact with reality.

Computer engineer, M.S. in Applied AI, two years deep in the messiest data in advertising. I build the BigQuery pipelines, containerised GCP services and LLM applications behind media reporting for 10+ enterprise brands across MENA: Americana, IKEA, Amazon, Qatar Airways, Emirates Group and others.

Act I

The lab years

Four years of computer engineering at the Lebanese American University, then three internships that had nothing in common. This is where I learned to distrust a number until I understood the instrument that produced it.

WITH origins AS (

It started with hardware, not spreadsheets.

I trained as a computer engineer at the Lebanese American University: circuits, embedded systems, computer vision. Not a single course on marketing attribution.

That turns out to matter. Engineering teaches you to distrust a reading until you understand the instrument producing it. Every number I've shipped since has been treated the same way: where did this come from, what could be lying, and how would I know.

The last thing I built there before graduating became a paper, and a public dataset alongside it.

Published work
Towards Image-Based Material Estimation
IEEE M2VIP 2023

Five surface materials a walking robot might cross, photographed under varying light with a Raspberry Pi camera, each image paired with a friction coefficient measured on a rig I built from a DC motor and an encoder. MobileNetV2 and InceptionV3 both classified it above 90%. Presented at the International Conference on Mechatronics and Machine Vision in Practice. Advised by Dr. Noel Maalouf.

Robot Quadruped Materials Dataset
IEEE DataPort

The labelled dataset behind the paper, published openly so the result could actually be reproduced.

Education

M.S. Applied Artificial IntelligenceLebanese American University2025–26
B.E. Computer EngineeringLebanese American University2019–23

Certifications

IBM AI Engineering
Google Advanced Data Analytics
Google Analytics Individual Qualification

Languages

Arabicnative
Englishfluent
Frenchfluent
Spanishbasic
), the_lab AS (

The undergraduate builds, and the one that got published.

Undergrad is where you get to solve a problem badly three times before you solve it well, and nobody's budget depends on it. Mine went mostly on vision and language, with detours through digital logic, distributed ledgers and tree algorithms.

Reading these back, they have one thing in common I did not plan. In every one of them the interesting work turned out to be upstream of the model. Building a dataset that did not exist. Deciding what a letter looks like in numbers. Working out which signs a glove physically cannot tell apart. The model was usually the least surprising part, and the part I would now spend the least time on.

Real-time ASL to text

A sign language translator, and a hardware project before it was ever a vision one. The input was a glove: a variable resistor along each finger, so bending a finger changes its resistance, read by custom hardware and matched against a lookup table of bend signatures. No model, no camera, no inference. Five numbers and a table.

That works until it doesn't, and the way it fails is the interesting part. A flex sensor measures how far a finger bends and nothing about where it ends up. R, U and V all hold the index and middle fingers straight with the rest closed, so all three produce the same five resistances. The glove is not being inaccurate. The information simply is not in the signal.

So MediaPipe went in as a tiebreaker rather than as the classifier. The lookup table answers whenever a signature is unique, and only when it returns more than one candidate does the camera get consulted, measuring the lateral distance between the index and middle fingertips normalised by hand size so it holds at any distance from the lens. Pick a letter below and watch which stage actually decides it.

what the glove reports
Pick a sign

Bend values are the shape a flex-sensor glove reports: five resistances and nothing else. The two-stage resolution is the real design, and the thresholds are the ones in the project source, where the normalised index-to-middle fingertip distance splits at 0.07 and 0.12. Individual sensor readings here are representative rather than captured.

Image-based material estimation PUBLISHED

A quadruped robot choosing where to put a foot needs to know whether the surface will hold before it commits weight to it. This was a convolutional network predicting material class and friction behaviour from an image alone, built for the quadruped the lab was developing.

The dataset did not exist, so most of the work was building it. The images were the easy half. The friction coefficients meant building a rig: a DC motor with an encoder, dragging each surface, deriving the coefficient from the measurement. Five materials, several lighting conditions, every sample labelled by hand.

The rig is the part I would defend hardest. Measuring a friction coefficient properly means measuring a force, so each surface got dragged by a DC motor with an encoder on it, and the coefficient came out of the relationship between the current drawn and the movement recorded. That is a mechatronics problem sitting underneath a computer vision problem, and doing it by hand across five materials and several lighting conditions is where most of the term went.

Two architectures were trained on it, MobileNetV2 and InceptionV3, and both classified material above 90%. I am deliberately not making more of that than it deserves: the dataset is small and it is ours, so a high score on it is a starting point rather than a finding.

The work was presented at M2VIP, the International Conference on Mechatronics and Machine Vision in Practice, and the dataset went out alongside the paper. That mattered more to me than the accuracy did. A result nobody else can reproduce is barely a result, and the dataset is the one part of this project someone else can pick up and argue with.

The pipeline

Tap any stage to see what happens there.

accuracy  > 90%
venue     IEEE M2VIP 2023
dataset   IEEE DataPort
Towards Image-Based Material Estimation, IEEE M2VIP 2023 · Robot Quadruped Materials Dataset, IEEE DataPort.

Handwriting, described in numbers first

Sixty-two classes, 3,400 images, and a constraint that turned out to be the whole point of the course: we had to say what a character looks like in numbers before any model was allowed to see it. Vertical, horizontal and diagonal symmetry. Projection histograms counting dark pixels down every column and across every row. Histogram of oriented gradients. All of it written to a CSV, and the models trained on that table rather than on the picture.

Then we trained a convolutional network, which skipped the exercise entirely and found its own features. It reached 95% on the training set and 75% on validation. The twenty-point gap taught me more than the accuracy did. The hand-built features topped out at 70% and were honest about it the whole way.

Draw something in the box. Every number on the right is computed from your strokes.

Draw a letter or a digit
Extracted from your strokes
Nearest match

The strips beside and beneath the canvas are the projection histograms: activated pixels per row and per column. They are the cheapest useful description of a shape you can compute, which is why the same two arrays turn up again in the Oculi work later on.

Features are computed live from your drawing, using the same set the project used. The match is a nearest neighbour over five typefaces per character, scored by cosine distance on the blurred shape plus the same projection and gradient features. It is not the trained ensemble, which needed the 3,400-image dataset. I and O are held out because at this resolution they are the same shape as 1 and 0. Reported project results: tuned ANN 70%, bagging 68%, voting ensemble 70%, CNN 95% train and 75% validation.

And a term of it was hardware

Half this degree was spent below the software line: logic design, circuits, microprocessors, and an FPGA course whose final project was a Verilog build on an Altera board.

The full build was a calculator on an Altera board, and almost none of it was arithmetic. A PS/2 keyboard controller had to decode scan codes arriving on a clocked serial line, a state machine had to track what had been typed and what it meant, an LCD driver had to hold timing the display would accept, and only then did anything add up.

Hardware teaches a lesson software lets you avoid: everything happens at once unless you make it not. There is no call stack to reason along, only signals settling between clock edges, and a design that works at one clock speed and fails at another is telling you something about your own assumptions rather than about the board.

The decoder below is the smallest module in the project and the one I still reach for as an example. Sixteen inputs, sixteen outputs, no clock, no state, nothing to go wrong at three in the morning. Most of what I now like about a good function I first liked about a case statement.

segment7.v
The case statement is copied verbatim from the project source. Bits run a through g from the most significant end, and the inversion drives a common-anode display, which is why every literal is written negated.

Putting a national vote on a chain

A software engineering course, run the way the course wanted it run: Jira board, sprints, backlog grooming, use cases written before a line of code. What the team built was a full e-government platform, not a demo. A citizen could register and sign in, request an official document, pay the fee for it, book an appointment at an office and find the nearest branch. React on the front, Node and Express behind it, a REST API over a real database, and separate controllers for users, requests, forms, appointments, locations and transactions.

My piece was the voting, and the reason it is worth building that way is corruption. In a lot of places the problem with an election is not that the count is hard, it is that nobody outside the room that holds the ledger has any way to check it. The property you need is not that the tally is correct now. It is that nobody can quietly make it incorrect later and have it still look untouched. A database gives you the first and not the second, because whoever administers it can rewrite history and leave no trace.

So the ballot went onto a smart contract. The candidate list and the running counts live on chain, the browser talks to the contract through the voter's own wallet, and casting a vote is a transaction that has to be mined before it counts for anything. Every vote is public, permanent and independently checkable, and no single party owns the record. A voter can verify their own ballot landed without trusting the people running the election.

The rule that does the real work is one line: an address that has already voted cannot vote again. Not enforced by the interface, which anyone can bypass with the developer console, but by the contract, which nobody can. Cast a ballot below, then try to cast a second one. Then tamper with an earlier block and watch the chain stop agreeing with itself.

Connected as 0x…
Chain

Each block stores the vote, the hash of the block before it, and its own hash over both. Change an old vote and every hash after it stops matching, which is the whole reason the tally is worth anything.

The hashes are real SHA-256, computed in your browser over the block contents, and the double-vote rule is the one from the contract. There is no network here: a live version needs a wallet and a deployed contract, so this runs the same logic against a local chain.
), the_field AS (

Three internships, and the one that changed what I wanted to do.

Between graduating and Dubai I worked in three places with almost nothing in common: a sales startup in Washington, a semiconductor company in Beirut, and a shipping line. All three pointed at the two things I have been doing ever since, which are vision and prediction.

Remote · Washington, USA
AI / ML Engineering Intern · Salesdash

Two threads at once. A whitespace model over the sales pipeline, forecasting how much budget an account had left to spend. And a Slack chatbot built on LLMs, where the model was the easy part: the work was context and memory, deciding what the bot should still know three messages later and what it was better off forgetting. The first thing I shipped with a language model inside it.

Summer 2023 · 12 weeks · Beirut
Computer Vision Engineering Intern · Oculi

Extracted regions of interest from an event-driven vision sensor using nothing but projection histograms, then built the class that turned its output into a labelled training dataset.

Jul – Sep 2023 · Beirut
Data Science Intern · CMA CGM The HUB

Built a late-shipment prediction model at 90% accuracy, and applied GANs to tabular data for synthetic augmentation in logistics forecasting.

Aug 2024 – present · Dubai
Data Analyst, AI & Automation · Choreograph

Where the rest of this story happens.

Twelve weeks at a sensor company

Oculi builds a sensing and processing unit that does its thinking at the pixel, before anything becomes an image. The point of a part like that is bandwidth and power: instead of shipping frames off the sensor for something else to analyse, it ships actionable signals, which in the mode I worked in means two arrays per frame. Activated pixels per row, activated pixels per column.

My job was to put a box around a moving person using only those two arrays. No image. The first version was as direct as it sounds: threshold each array, take the first and last index above the threshold, and the four numbers you are left with are your bounding box.

It broke in two ways. A noisy background dragged the box out to the edges of the frame, and a subject close to the camera fragmented into several disjoint boxes instead of one. The fixes were merging adjacent boxes, padding the result, and smoothing the signals before thresholding them. I started smoothing with a linear kernel and moved to a Gaussian one, on the reasoning that a person's activation profile is roughly a bell centred on them, and that turned out to hold.

It never fully worked. When someone's body moved less than their arms, the algorithm tracked the arms and lost the torso, and I handed it over in that state. The brief when I arrived had been a fitness tracker, and halfway through it was set aside for something more valuable, which was the dataset underneath it. I annotated 3,960 frames by hand in CVAT, a box around the subject's hands in every one of them, exported to YOLO format, and wrote the class that turned any video into per-frame records: frame number, video name, dimensions, the projection histograms and the intensity sums per row and column, and the labels when they existed. Then a low-light dataset rebuilt from stills into video, and Doxygen comments through the whole codebase so the next person could pick it up.

Before any of that, the first weeks went on the part I still find most interesting: the sensor does not have one output, it has nine, and each one throws away something different. The whole argument for a part like this is bandwidth, so every mode carries a measured number, and choosing a mode is choosing what you are willing not to know.

Output mode
100.00 % of full-frame bandwidth

These nine bandwidth figures are measured, read off the SPU test harness in my internship report: full frame 100%, events by polarity 70.72%, smart events on motion 20.56%, and so on. The bar chart and the saving arithmetic are computed from them. What the modes each keep is described from the report rather than rendered from sensor output, because the sensor is not in your browser.

The brief I arrived with was a smart fitness tracker: read posture and movement and tell someone whether they were doing an exercise properly, using the sensor together with pose landmarks. I evaluated the two obvious libraries and neither survived contact. MediaPipe was accurate and power-hungry, because it resizes every frame, which is exactly the cost a sensor like this exists to avoid. OpenPose did not detect people reliably in this footage at all. That result is what pushed the work back toward deriving the region of interest from the actionable signals directly, and it is the most useful negative result I got there.

Region of interest, from two histograms
input 2 arrays per frame
naive threshold, first and last index
failed on background noise, split boxes
fixed withmerge, pad, Gaussian smoothing
still opentorso lost when arms dominate

Two arrays counting activated pixels per row and per column. The same pair of features I had hand-built for a character recogniser the year before, arriving this time out of a chip instead of out of a CSV.

Act II

The day job

Since August 2024 I've been a data analyst at Choreograph, WPP's data and AI company, in Dubai. Here's what that actually means.

Rebuilding CTGAN to find out how it works

At the shipping line the headline task was a late-shipment prediction model, and the more useful one was the reason we needed synthetic data at all. Real logistics data is unbalanced in the ways that matter: the delays you most want to predict are the rarest rows you have.

The standard answer is CTGAN, a generative adversarial network built specifically for tabular data. We could have imported it. Instead we reimplemented it in PyTorch, which is the decision I would make again, because a library you have rebuilt is a library you can debug.

What rebuilding it teaches is why it is not simply a GAN pointed at a spreadsheet. Two problems break the naive version. Continuous columns in tabular data are usually multimodal, so normalising a whole column to one scale asks the generator to model a shape it cannot represent, and it settles on the average of the modes: a value the real data never takes. And categorical columns are imbalanced, so a class holding three percent of the rows is close to invisible to a generator that samples by frequency.

CTGAN answers the first with mode-specific normalisation and the second with conditional training-by-sampling. Switch between them below.

Generator
real column generated
Plain GAN

Rare category survival
An illustration of the failure CTGAN exists to fix, not a capture of our training runs. The two distributions are drawn here and the transit-time modes are invented, but the arithmetic is live: the generated density, the total variation distance and the per-category survival rates are all computed in the browser from the curves on screen. The paper's own framing is that tabular columns are multimodal and their categories imbalanced, and that a generator which ignores both will average across them.
), the_work AS (

Turning a dozen scattered sources into one honest picture.

Brands spend money advertising across a lot of different platforms. Each one reports back in its own format, on its own schedule, counting things its own way. Somebody has to turn all of that into a single view a marketing team can actually make decisions from.

That's the job. I build the pipelines that collect the data, the models that make it comparable across platforms, and the dashboards people read. Then the automation that keeps it all running without me touching it.

The less obvious part is the joining. Advertising data on its own only tells you what an ad did. Put it next to app installs, brand tracking and sales figures and you can start asking whether any of it mattered.

Platforms

MetaTikTokGoogle AdsDV360SnapchatAmazon DSPApple Search AdsPinterestAdjust

Brands

KFCHardee'sPizza HutIKEA MENAAmazonQatar AirwaysEmirates GroupflynasSGMBHuaweiHONORAmEx KSA

Built with

BigQueryLooker StudioPower BITableauGoogle Apps ScriptPythonCloud Run
20+
pipelines & dashboards
30+
automations shipped
10+
brands supported
7
MENA markets

The creative pipeline, and why it had to exist

Every platform will tell you how an ad performed. Meta, Snapchat, TikTok, the rest of them: impressions, clicks, completion, all of it available through an aggregator API. What none of them will tell you is why one ad worked and another did not, because the thing that actually varies is the creative, and the creative is not in the numbers.

The aggregator does hand you a URL for each creative. So the obvious move is to fetch the asset, look at it, and join what you see to how it did. Except the URLs expire. They are CDN links with a lifetime, and once one goes stale the asset behind it is gone as far as you are concerned. The performance row survives in the warehouse forever. The thing it describes does not.

That asymmetry is the whole problem. It means creative analysis has a shelf life measured in weeks, and any question of the form "what has worked for this brand over the last year" is unanswerable, not because the data is missing but because the pictures are.

So the first thing I built is deliberately unglamorous: a job that takes every creative URL the aggregator reports, downloads the asset while the link still resolves, and writes it to a Cloud Storage bucket keyed to the creative id. Images and video both. Nothing clever, just durable.

The second thing is what the first one makes possible. With the assets held, each one goes through metadata extraction: computer vision for the measurable properties, and generative models for the descriptive ones a classifier would struggle to name. Faces and how many. Text and how much of the frame it occupies. Dominant colour and whether the palette runs warm or cold. Whether a logo is present and where. For video, duration, cut rate and motion. Then all of that gets joined to the performance rows, so the question stops being which ad won and starts being which properties of a creative are associated with winning.

Below is the shape of that analysis. The correlations are computed live from the set on screen. Then turn off the bucket and watch what happens to your evidence.

Asset source
Extracted metadata
Associated with performance
The creatives here are drawn in the browser, not client assets, and their performance figures are invented. What is real is the method and the arithmetic: the feature values are the kind of metadata the extraction step produces, and every correlation on the right is a Pearson coefficient computed live over whichever creatives are currently available. Switching the source to expiring links removes the assets whose URLs have lapsed, and the coefficients then recompute over what is left, which is exactly the problem the bucket was built to prevent.

Three applications, shipped

Three internal tools, all React and TypeScript over a Postgres backend, each built to a deadline rather than to a specification. The interesting part of every one of them was never the interface. It was the set of decisions that determine whether a thing is safe to put real data in, and whether the numbers it shows mean anything, and that is what each of these write-ups is about.

Scoring a billboard for a specific audience

The first is an out-of-home mapper: every poster, screen, mupi and wall wrap a brand runs, placed on a map by latitude and longitude, tagged with format, media owner, price, campaign dates and the year it ran. That last field is what makes it more than an inventory list, because with a year on every row you can put 2025 next to 2026, or your own sites next to a competitor's, and see how a market moved.

A map of billboards is only useful if you can rank them, and ranking them is where the actual thinking is. A site is not good or bad in the abstract. It is good or bad for somebody. So sites are scored per audience segment, and the score is a composite of things that can be measured about a location rather than a number a media owner tells you: how well the surrounding area matches the segment, how dense the places around it are, how much traffic passes it, how congested that traffic is, and what nearby venues pull people past that spot in the first place.

Two of those deserve a note. Congestion cuts both ways, and that is why it carries a negative weight for most segments and a positive one for commuters: a jam is dwell time if your audience is sitting in it and a reason to look away if they are not. And the magnet list is the part planners actually argue with, because "this screen scores well because of the stadium and the food court next to it" is a claim someone can check, where a single number is not.

Pick a segment below and the ranking reorders. The same physical billboard moves several places depending on who you are trying to reach.

Audience
0 composite score
Factors and their weights
The scoring factors are the real ones, taken from the schema: audience affinity, place density, traffic pressure, congestion and magnet pull, stored uniquely per location and audience segment alongside the venues driving the pull. The six sites here and their values are invented, and so are the segment weights, but every composite, ranking and cost-per-point on this page is computed live from them. Enrichment in the real tool comes from third-party place, traffic and event APIs, cached in the database so the same site is not paid for twice.

The same idea, pointed at whitespace

The second is a sibling of the first, built for a different question. Rather than scoring sites you already run, it holds the available inventory in a market as bookable stock: typed by format, tracked by whether it is available, booked or under maintenance, attached to campaigns with their assets, and enriched with what surrounds each site.

The layer that makes it worth using is beside the inventory rather than in it. Your own stores and your competitors' stores sit on the same map, so the question a planner asks stops being which billboard is free and becomes which free billboard is close to one of my restaurants and far from one of theirs. That is a whitespace question, and it is the same shape as the very first model I ever built at Salesdash, which is not a coincidence I noticed until I wrote this page.

It also has the thing every internal tool eventually needs and nobody enjoys building: an approval queue. New accounts land in a pending state and an administrator grants a role, because a tool holding a client's media plan should not be self-service.

A CRM is a different category of thing

The third is a lead manager for a property developer. Enquiries arrive from social ads, get assigned to one of the sales team, and move through a pipeline from new to contacted to qualified to won or lost, with follow-ups and an activity trail against each one, and automated messages triggered along the way.

This is the one that taught me something, because a dashboard holds numbers and this holds people's names and phone numbers. Three decisions in it are mine and none of them were things I was asked for.

Personal data is encrypted at rest, not merely access-controlled. Names and contact details are encrypted on write, decryption is a privileged function explicitly revoked from anonymous access, and the application reads through views rather than touching the columns. A leaked read-only credential yields ciphertext instead of a contact list worth selling.

Email goes through a queue with a dead letter queue behind it. Sending inline from a request handler works right up until the provider is slow, and then it fails quietly and nobody can say which leads were never contacted. And a suppression list of unsubscribes, bounces and complaints is checked before anything is queued, because the fastest way to lose a sending domain is to keep mailing people who asked you to stop.

Step through it below, then break it. The failure paths are the point.

Conditions
Lead captured


          
The sequence and the guards are the real ones: encryption on write with decryption revoked from anonymous access, row level security deciding which rows a rep sees, a suppression check ahead of the queue, and a dead letter queue written to before the source message is deleted so a crash between the two duplicates rather than loses. Payloads and names here are invented. Across the three applications: nineteen tables with row level security, seventy policies, and eighteen privileged functions pinned to an empty search path.

Asking the warehouse a question in English

Every question about performance used to pass through someone who could write SQL, and that someone was usually me. A planner who needed one number waited a day for it, and I spent the day writing queries instead of building things.

So the question became the interface. Plain language goes in, a model plans a query against a documented catalogue of tables, the plan gets checked before anything is allowed to run, and the answer comes back with a chart. The catalogue is the part that actually matters: every table written down with what it means and which kinds of question it is allowed to answer. Two brands run the same skeleton and only the catalogue differs.

The bug that taught me the most had nothing to do with the model. Results were silently coming back empty. The code asked the warehouse for results once and read whatever came back, without checking whether the query had actually finished or asking for the next page. On a big slow query the warehouse replies that it is not done yet, and the app reported that as a genuine answer of zero. Correct query, no error anywhere, confident wrong answer. The fix is two lines and it took a week to find.

Ask it something below. Then turn on the switch marked read once and ask again.

Ask
Answer

The questions, the plan, the catalogue check and the numbers here are illustrative of the shape of the system, not extracts from a client's warehouse. What is faithful is the sequence, the fact that a plan is validated before execution, and the failure mode behind the read once switch: a query that has not finished returns an empty first page, and code that does not check for completion reports that emptiness as a genuine answer of zero.

Rewriting content for whoever is reading it

One piece of content often has to work for several different audiences: a trade reader who wants the detail, a general reader who wants the point, someone much younger who wants both in shorter sentences. Doing that by hand means writing it three times.

So the audience became an input. You give the tool the text and a description of who it is for, which is platform, region, education level and age, plus a target reading grade and a target length, and a model does the rewrite.

Whether it landed is the part you can actually check, because reading level is measurable. The tool scores roughly twenty-five properties of the text before and after. Pick an audience below and the same paragraph gets rewritten for it, with both versions scored live. Then read what the score says about whether the rewrite hit what it was asked for.

Two faults I would fix in that order. The loop never closes: the model is asked for a grade it cannot measure, the app measures afterwards, and nothing retries on a miss, which makes the score a report card rather than a control signal. And if the model returns malformed JSON, both versions fall back to the original text and report success, so a parse failure is indistinguishable from no change needed. Same shape as the empty-results bug above, and the same lesson: the dangerous failures are the ones that look like answers.

Rewrite for
before0.0
after0.0
Both columns are scored live in your browser with the same Flesch-Kincaid and reading-ease formulas the tool uses, and the audience bands are the ones in its source. Syllables use a vowel-group heuristic rather than a pronunciation dictionary, so counts land within a syllable of the Python original. The rewrites shown are prepared, because generating them needs a model and this page has no backend. Edit the left column and the right one steps aside: scoring still runs, rewriting cannot.

Where a platform has no off-the-shelf connector, I write the small service that goes and gets the data. Where a report gets rebuilt by hand every week, I automate it so it just arrives. And there's a monitor that checks every morning whether yesterday's data actually landed, because finding a broken pipeline yourself is a much better morning than hearing about it from a client.

), the_shift AS (

Then the job started changing.

Over the past year less of my time has gone on answering questions and more on building the things that answer them.

The conversational analytics product above is the clearest example, and it shipped as two brand instances of one system. The interesting problem there was never the model. It was making the thing say I don't know rather than invent something confident and wrong, which is the same instinct that made me chase an answer of zero until I found out it was an unfinished query rather than an empty result.

Alongside that I set AI tooling standards for my team as part of an internal AI committee, and ran the evaluation on which platforms we should actually be paying for versus building ourselves.

This is the direction I want to keep going in, which is most of why this site exists.

Act III

The deeper end

A master's degree, a thesis, and the projects where I got to choose the problem. This is where I find out what I actually believe about building software.

), the_masters AS (

Back to the same university, this time with a job.

An M.S. in Applied AI taken alongside full-time work, which is a good filter. You stop doing the assignment that impresses and start doing the one you will still understand at eleven at night.

Two of those projects produced work I would defend in a room. One of them is the model I was proudest of, and it lost.

Chest radiograph triage

The task was abnormality triage on the MIMIC-CXR chest X-ray set: flag the films a radiologist should read first. 383 training images, 102 validation, 184 test, split by patient rather than by image so the same chest could not appear on both sides of the line.

The baselines came first: logistic regression, k-nearest neighbours and a random forest on radiomics features. Then a fine-tuned EfficientNet-B4, which beat only one of them. Then the idea I liked best, a U-Net that would learn segmentation and classification together so the classifier had to look at the lung rather than at the whole frame.

It scored 0.53, which is a coin toss. Two epochs on 383 images with masks that were not good enough. That is not evidence that segmentation guidance is a bad idea, it is evidence that I did not have the compute to test it, and those are different sentences. What did work was going wider instead of cleverer: fusing the image with the tabular features lifted the whole thing into the nineties.

The other thing a term on medical imaging teaches you is that AUC is not the number that decides anything. A triage tool has to be given a threshold, and the threshold is where the actual argument lives. Flag too eagerly and the radiologist reads the whole pile anyway. Flag too cautiously and you have sent an abnormal chest to the back of the queue.

Pick a model and drag the threshold. The two curves are the model's score distributions for normal and abnormal films, and the counts on the right are what a shift with 184 films would actually experience. Watch the missed column, because that is the one a hospital would ask about first. Put the U-Net up and the two curves sit almost on top of each other, which is what 0.53 looks like when you stop reporting it as a number.

Model
normal film abnormal film
What the shift actually sees

The AUC of each model is as measured in the project, and the split is the real one: 184 test films at roughly 0.57 prevalence, so 105 abnormal and 79 normal. The two distributions are a binormal reconstruction from the AUC rather than stored per-film scores, and every count below follows exactly from them. The report's calibration, fairness-gap, robustness and cost figures were simulated to satisfy the rubric and are deliberately absent.

A graph recommender, and a number I would rather show than bury

Customers and products as two sets of nodes, a purchase as an edge between them, and a graph convolutional network learning to score the edges that do not exist yet. Trained on 272,404 real transactions across 3,647 customers and 3,538 products, early-stopped at epoch 21 with a best validation MSE of 0.1435, then measured against a NeuMF baseline and against what Neo4j returns for the same customer.

Precision at ten came out at 3.7%. Roughly one recommendation in every batch of ten lands. Written down cold that reads like a failure, and on a catalogue of 3,538 items where the average basket holds a handful, guessing at random gets you about a tenth of a percent. So the model is around thirty times better than chance and still nowhere near good enough to show a customer, and I need both halves of that sentence to be sayable out loud.

Pick a customer. Their purchases light up and the panel ranks what the co-occurrence baseline would put in front of them next, with the real product names out of the catalogue.

Those names are worth reading, because they show you what the model does and does not know. Customer 14258 gets LED string lights and then a fringe table skirt: both party supplies, and a genuinely sensible pair. Customer 13187 gets compression stockings, a paintball harness and a music box. The model has no idea what any of these objects are. It only knows which ones ended up in baskets together, which is enough to find real structure sometimes and noise the rest of the time. That gap is what the precision number is measuring.

Customer
Recommended next
Seven real customers with their real attributes, and sixteen real products with the names, categories and prices from the project's own catalogue. Scores are item-to-item co-occurrence over the full 272,404-row order table, normalised by how often each product sells so the bestsellers cannot win by default. That is the graph baseline the GCNN was measured against, not the trained network. Reported model results: validation MSE 0.1435, precision@10 0.037, recall@10 0.042.

Licence plates that the off-the-shelf answer cannot read

Lebanese plates mix Arabic and Latin script on the same plate, and the colour encodes the vehicle class: white for private, red for taxis, green for rental. Every commercially available recognition system is trained on standardised Latin plates photographed in good light, so pointed at a Beirut street it does not degrade gracefully, it simply does not know what it is looking at.

The pipeline was detection then recognition: YOLO locates the plate in the frame, the crop goes to a recognition stage that reads the characters. Splitting it that way means the two failures are separable, and you can tell whether you failed to find the plate or failed to read it, which matters because they have completely different fixes.

The most useful thing the project produced was the log of frames where detection returned nothing at all. A recognition score averaged over the frames you managed to detect flatters you, because the hard frames quietly leave the denominator. Angled shots, motion blur and night plates are exactly the frames a real deployment is full of.

A pipeline that has to survive three in the morning

The data engineering term ended in something deliberately unglamorous: retail sales read from CSV, daily weather pulled from an API, both cleaned, merged and written into MongoDB as two collections, with an Airflow DAG running the whole thing at 03:00 and a Dash app reading off the result.

Nothing about that is technically hard, and that is the point of building it. Every interesting failure mode of a pipeline happens in the hour when nobody is watching. The API is down. Or it answers, but with a partial day. Or it renames a field and returns a perfectly valid response that means something different from yesterday. The job either notices, or it writes something wrong into a table that six dashboards trust, and nobody finds out until a number looks odd in a meeting.

So the transforms were the easy half and the logging was the half worth doing carefully. It is also the closest thing in the degree to what I do at work, which is why I chose it. I wanted the version of this problem where I owned every layer, including the one that has to survive three in the morning.

What transfer learning is actually worth

The computer vision term had a question I wanted a number for rather than an opinion: on a small dataset, how much does a pretrained encoder really buy you? So I built the same U-Net twice, once from scratch and once with a frozen VGG16 encoder and a mirrored decoder, and compared them on the same segmentation task.

The from-scratch version has to learn edges, texture and shape from a few hundred images. The VGG16 version already knows what an edge is, having been shown a million photographs of other things entirely, and only has to learn what to do with that knowledge. On a small dataset that head start is most of the performance, which is the argument for transfer learning in one sentence.

Building both is slower than reading a paper about it, and it is the only way the answer belongs to you. I would rather have measured it once myself than be able to cite it. The rest of the degree was recommender systems, data visualisation, statistics and a term on ethics I expected to resent and did not.

), the_thesis AS (

Grad school, and a voice agent that had to survive a phone call.

I started the M.S. in Applied AI at LAU in 2025, online, while working full time in Dubai. The thesis is LIRION: a modular real-time voice agent for telephony.

Voice agents that look fine in a browser demo fall apart on a phone call. Telephony hands you a hard latency budget, real turn-taking, barge-in, and no visual fallback when something goes wrong. I picked it precisely because it is the version of the problem where the shortcuts stop working.

The architecture is a Listen, Think, Act, Speak loop with a hard wall between dialogue reasoning and backend execution, and the wall is a schema. The model is not allowed to emit instructions. It must return six fields: what to say, an optional action name, that action's parameters, a state update, a log update, and a flag that ends the call. The orchestrator reads nothing else, so an action the model invents simply is not in the dispatch table and dies there.

The rule I would defend hardest is the ordering. Execution resolves before the final reply is written. The tool result gets appended to conversational state and handed back to the model, which then answers against a verified outcome instead of a prediction, so a caller can never be told a booking exists before it does. Writes are gated on reads for the same reason: the agent is instructed never to book without checking availability first, which is what makes double-booking structurally impossible rather than merely unlikely.

Pick what the caller says and run the turn, or step through it one stage at a time. The reschedule case is the one I would point at: it books the new slot before cancelling the old, because doing it the other way round means a failure halfway through leaves someone with no appointment at all.

Caller says
Decision object


          
The six-field decision schema, the action names and the shape of every result dictionary are taken from the published source: src/agent.py for the decision object, src/tools.py for the dispatch and the returns, src/voice_loop.py for the turn loop. Transcripts, ids and dates are illustrative; the shapes are not. The ordering is the part that matters: execution resolves before the final response is generated, so the caller is never told an action succeeded until it has.

Answering in Arabic without a second prompt

A bilingual agent is usually built the expensive way, by maintaining one prompt per language and watching them drift apart. LIRION does the opposite: the Agent is instructed to always reason and reply in English, and a translation layer localises the reply on the way out. One prompt, one set of booking rules, one place to fix a bug.

Detection has a deliberate shortcut in front of it. Arabic script occupies a known Unicode block, so a regular expression catches it before any model is called at all, and the language round trip disappears for the case that needs it most. Only genuinely ambiguous input falls through to a detection call, and that call runs at temperature zero because language detection is not a task that benefits from imagination.

The translation prompt carries one instruction that matters more than the rest: preserve names, phone numbers, dates, times, emails and identifiers exactly. A translator that helpfully reformats 15:00 or localises a phone number has silently corrupted a booking. And both functions fail safe, returning the original text rather than an exception, because a translation failure should degrade a call rather than end one.

Asking the call data questions, safely

Once the system had been taking calls for a while, the interesting questions moved from the calls to the data behind them. So LIRION got a second agent whose only job is to turn a business question into read-only SQL, run it, and describe the answer. It reads a documented schema from a file rather than introspecting the database, which means the model sees the tables it is supposed to see and nothing else.

The part I would defend in an interview is that the safety is not left to the prompt. The instructions say SELECT only, and then the code checks: the generated statement has to begin with select, and it is scanned for insert, update, delete, alter, drop, truncate and create before it goes anywhere near a connection. Two independent guards, because a prompt is a request and not a constraint. Listings are capped with a LIMIT, and when the question is genuinely underspecified the agent asks one follow-up question rather than guessing at a time window.

I built this before I built the same pattern at work, and finding out afterwards that I had arrived at the same shape twice, a documented catalogue plus a validated plan plus execution last, did more for my confidence in it than either project did on its own.

Metrics that refuse to double-count

Every state change writes a row to an event table rather than only mutating the appointment. That is ordinary event sourcing, but the event taxonomy is where the thinking went. A reschedule does not emit one event, it emits two: the new booking, and the cancellation of the old one, each labelled as belonging to a reschedule rather than to a fresh decision.

The reason is that the naive version lies. Count bookings without that distinction and every reschedule inflates your booking figure while its cancellation deflates your retention figure, so a busy week of customers moving appointments looks like a week of churn and growth at the same time. Deciding what counts as an event is a measurement decision disguised as a schema decision, and getting it wrong is invisible until someone builds a dashboard on it.

And the parts nobody demos

A voice agent that only answers the phone is a prototype. What made this feel finished was the unglamorous surface around it: a dashboard reading the event tables, a job that emails each staff member their own appointments for the next day, and a two-way Google Calendar sync so the people doing the work never have to open the system that booked them.

The calendar sync is the piece I would rebuild first. Two systems that both believe they own an appointment will eventually disagree, and reconciling that properly is a harder problem than anything in the conversational layer.

), build_log AS (

Five builds, and what each one actually taught me.

The academic work is above. These are the ones nobody assigned me. A list of project names tells you nothing, so each of these opens into what broke and what I did about it.

The last entry on that list is a thing I have not built. It's there on purpose. A portfolio that only shows finished work is telling you what someone wants to be believed about them, not what they're actually doing.

), toolkit AS (

What I actually reach for.

Listed by how often I use it, not by how it looks on a CV.

Data & warehousing

BigQueryAdvanced SQLWindow functionsQuery optimisationData modellingTaxonomy parsingSupermetricsData QA & reconciliation

Cloud & engineering

Google Cloud PlatformCloud RunCloud FunctionsCloud SchedulerCloud BuildFirestoreSecret ManagerDockerVerilog & digital designPythonJavaScriptFastAPIStreamlitREST APIs & webhooksGit & GitHubLinuxPostgreSQL

ML & modelling

PyTorchTensorFlowscikit-learnHugging FaceXGBoostCNNs & RNNsTransformersOpenCVMediaPipepandasNumPyPredictive modelling

Automation & reporting

Google Apps ScriptLooker StudioPower BITableauScheduled pipelinesAnomaly detectionFreshness monitoringAutomated distributionExcel / Sheets API

AI & applied ML

LLM application designNL-to-SQLRAGPrompt engineeringTool use & function callingMCPClaude API & Claude CodeOpenAI APILangChainVoice agent architecture

Media & marketing data

Paid media measurementAttribution (Adjust)DV360 · Meta · TikTokGoogle Ads · SnapchatAmazon DSP · PinterestBrand tracking (YouGov)Pacing & planning

Product & craft

Stakeholder translationTechnical documentationExpo / React NativeSupabaseRapid prototypingInternal AI CommitteeBuild-vs-buy evaluation