Can you train a cyberattack detector without ever showing it a real cyberattack?
It sounds like a contradiction. If you want a neural network to detect lateral movement, you would expect to show it lateral movement. I did the opposite: I generated an entire corporate network with its login history, staged an attack inside that artificial world, and trained networks on it. Not a single real row in the training data. The whole world is a 135-line config; each network has four thousand parameters and trains in seconds on a laptop, and the best result came from six of them, trained on six different invented worlds.
Then I pointed them at real data: the authentication logs of Los Alamos National Laboratory, 1.65 billion events, with red-team exercises labelled in them.
And it worked. The networks rank 3.6 million windows by suspicion, and the top twenty-three rows of that list hold sixteen real attacks and seven false alarms: all the analyst has to do is open those rows. A threshold counter on the same data needs a hundred and sixty-one thousand false alarms to reach the sixteenth attack. By AUC the synthetic training landed inside the range of published research trained on real labelled data, although the two cannot be compared head-on, and I will explain why.
No miracle happened. Something else did: twice I wrote down an elegant conclusion, and twice the experiment made me take it back. And I found the loop that makes a data generator worth writing at all: the detector's error points at one specific machine, you work out which phenomenon your invented world lacks, you add two lines to the config, and the error is gone.
The rest, in order: who I am and why I needed this, what lateral movement is, how the invented world is built, what the exam showed, and how much of it is honest. If you do not need the theory, skip ahead to "The first result on real data".
I write TDCV2, an open-source constructor for test data, and I wanted to know what it is actually good for. Filling a table with fake Smiths is one thing; producing data that something working can be trained on is another. You can't check that on a toy, so I took a real information-security problem, trained a classifier on invented data only, and turned it loose on the logs of a real network at Los Alamos National Laboratory.
Three caveats, so the rest reads the way it should.
I am not a machine-learning specialist. Everything below is the work of a person who writes a data generator and decided to test it on a serious problem. If it looks to you like I'm reinventing a wheel somewhere, or reaching for the wrong tool - you're probably right.
I am not competing with anyone. The results from research groups further down are there as a coordinate system, so the reader has a sense of scale, not because I'm claiming a place in that row.
I wasn't counting on a result. Honestly, I expected the synthetic data to do worse! It went differently, and that's the interesting part. There was no miracle anyway, but two things happened that are more interesting than a miracle: a measured boundary of a whole class of methods, and a limit on training from generated data that I then retracted myself.
What lateral movement is
Since the headline opens with a bit of jargon, let me explain what this beast is first. All the more so because the phenomenon is built in a non-obvious way, and half of your intuition about "hacking" doesn't apply.
There's a common picture in which the attacker cracks a password and that's the end of it. In reality, getting a login somewhere is only the beginning, and it is almost never the place they wanted. The first thing to fall is somebody's workstation: a person opened an attachment, or a service with an old hole was exposed to the outside. There is nothing valuable on that machine. The valuable things are in the database, on the file server, in the domain controller - and those still have to be reached.
Reaching them is what's called lateral movement. In the MITRE ATT&CK classification it is a tactic of its own, TA0008, and its most troublesome technique is T1078, Valid Accounts.
How it spreads
The mechanism is simple. A captured machine almost always has somebody else's credentials lying on it: a cached domain login, a token in memory, a password in a maintenance script, a saved connection. The attacker takes them and logs in to the neighbouring machine - an ordinary login, done exactly the way a real employee does it. There they collect that machine's credentials. And round it goes.
After that it's arithmetic: every step grows both the number of machines and the number of available accounts, and every new account opens new machines. One workstation becomes ten, ten become a hundred.
How this spreads. In our data it was exactly like that: 94% of the labelled events came from a single foothold, and the exercise reached 301 machines in all
The speed is nothing like the movies. Each step takes hours, the whole picture unfolds over days and weeks. That, broadly, is the one piece of good news - there is time to notice.
Who this affects, and who it doesn't
Lateral movement needs somewhere to move, laterally. It lives in large corporate networks: hundreds and thousands of machines, a shared domain, service accounts with access to many places at once, administrators who legitimately go everywhere.
A small business is not threatened by this phenomenon as such - not because it is safer there, but because there is nowhere to "move". If a company is five people, three laptops and all the work in cloud services, then an attacker who has taken the accountant's laptop is already where the money is. They need no "tactic", it's all at hand. Such a company will have other problems, and those are caught differently.
So everything below is about a network with somewhere to go.
Why it is hard to catch
An awkward property of this phenomenon. Every single lateral-movement event is flawless: the password is correct, the answer is "success", the account is genuine, the domain is our own, the time is business hours. There is nothing anomalous in it, because it really is an ordinary login - just made by the wrong person.
To a signature detector, and to any threshold counter, it is indistinguishable from an employee doing their job.
An ordinary login on the left, lateral movement on the right. Every field is valid in both cases; a single event cannot tell them apart
Why I needed this particular problem
Now I can come back to where I started - the generator.
Testing a data constructor on a problem whose answer is known in advance isn't interesting. You want one where invented data will either work or fail honestly, and where the outcome can be measured rather than eyeballed. Lateral movement fitted for three reasons, and each of them matters.
- Nobody has labelled examples. To train a detector the usual way, you need examples of the attack in your own network. And to get them, you first have to be compromised, notice it, and label it. Synthetic data here isn't a shortcut, it's the only way - if it works at all.
- The phenomenon has a computable definition. "An account logs in where it has never been, from a machine where it has never been" is a rule, not a picture. And a rule can be written down as a config without a single real example in front of you.
- There is open data from a real network, with labelled ground truth in it. Which means the exam can be taken honestly: train on the invented, check on the live, and not grade your own homework.
The plan followed from that, and it was simple. Show the network not one real row - not in training, not in validation, not in threshold picking. Write the entire training world as a generator config. And then turn it loose on real logs and see what it finds there.
The exam data here is the open Los Alamos set Comprehensive, Multi-Source Cyber-Security Events: 58 days, 17,684 machines, 12,425 users, 1.65 billion events. What interests me is the authentication log.
The labelled truth in it is the record of a red team exercise. And it looks like this:
749 exercise events: 104 compromised accounts, 301 destination machines and a total of 4 sources. 94% of the events come from a single foothold.
One machine. A hundred stolen logins. Three hundred places.
The exercise activity is spread across the days extremely unevenly, and from here on I refer to three sets constantly. Everything below is counted in windows: a window is one machine for one hour, everything it did in that hour. A labelled window is one that caught at least one exercise event. To keep it straight, let's fix the names right away:
| set | what it is | windows | labelled |
|---|---|---|---|
| day 8 | the densest activity; debugging was done on it | 231,787 | 15 |
| day 12 | second densest, also a working day | 223,987 | 12 |
| held-out set | the other sixteen days, never touched | 3,600,398 | 64 |
Day 8 turned out to be easy - almost any model solves it. Day 12 is hard: on it the models finally differ. The held-out set is the only honest measure, and it will become clear why.
The rule that saved the project: the exam first
My first impulse was to sit down and write the generator config. Good thing I didn't.
I spent half a day opening up and measuring the real data before the first line of config. And it turned out the problem was not the one I had pictured: I was thinking about password guessing, and what's labelled is an entirely different phenomenon. Everything I would have written in advance would have gone in the bin, all of it.
The reconnaissance produced three facts that determined absolutely everything that followed.
The account name carries no useful signal
The main "victim" of the exercise - the account through which the most labelled events passed - appears 30,497 times in a clean four-hour slice. That is the busiest regular administrator, and catching them as an anomaly is pointless.
Which means the feature has to be not about whose account it is, but about where it came from. Not a property, but a relationship.
The naive counter is fooled by the shape of the network
The hypothesis "many different accounts from one machine is suspicious" breaks against the topology:
2996 2752 2686 2590 domain controllers 252 118 110 file servers 1 2 1 workstations The foothold with its hundred accounts falls between the controllers and the workstations, and is indistinguishable from a small server. With a counter over destination machines it is exactly the same story.
A threshold that catches the foothold covers half the servers. A threshold that leaves the servers alone misses the foothold
The real signal is the novelty of the connection
Lateral movement produces new edges in the login graph: the pair "account - machine it has never logged in from", or "machine - place it has never reached before". And here is the measurement that made the reconnaissance worth doing. The share of new edges in normal activity:
| share of new edges in normal activity | value |
|---|---|
| median | 0.0000 |
| 90th percentile | 0.0000 |
| 99th percentile | 0.5000 |
For those new to this. A percentile is "the share of windows whose value is no larger than this". The 90th percentile being zero means nine windows out of ten have no new edges at all; and only one window in a hundred gets a share of new edges as high as one half.
People walk well-worn routes. The background is almost perfectly clean, and that is exactly what makes the problem solvable in principle.
An ordinary day adds zero new edges to the login graph. Lateral movement consists of almost nothing else
A world in a hundred and thirty lines
Only now can the generator be written - already knowing exactly what to produce.
The key idea: don't imitate lateral movement, produce its definition. The phenomenon has a computable formulation - "an account logs in where it has never been, from a machine where it has never been". Since the definition is algorithmic, it is synthesizable without losing the essence.
The heart of the config looks like this:
<pool name="Work" count="290"> <sequence name="hid"><gen type="increment" value="1"/></sequence> <sequence name="role"><gen type="text" value="0,2,3,4" percent="78.8,0.6,20,0.6"/></sequence> <sequence name="userBase"><gen type="formula" expr="hid * 30"/></sequence> <sequence name="userSpan"><gen type="number" value="1..4"/></sequence> <sequence name="fgBase"><gen type="number" value="1..290"/></sequence> <sequence name="fgSpan"><gen type="number" value="2..60"/></sequence> ... </pool> The machines live in a lookup table, each with its own number, its own role and its own circle of accounts: the machine with number hid owns accounts from hid*30 to hid*30+span. Separately it has a compromise circle - the numbers of other machines whose accounts travel through it. The width of that circle runs from 2 to 60, and that isn't sloppiness but a deliberate spread: the network has to learn the class, not one particular preset.
The account number for an event is then assembled by a formula:
<sequence name="User"> <gen type="formula" expr="Foreign == 1 ? ((W.fgBase + floor(hash(N, 1) * W.fgSpan)) % 290) * 30 + floor(hash(N, 18) * 2) : UserBase + floor(hash(N, 1) * UserSpan)"/> </sequence> The formula is abridged here: the service-account and churn branches are left out, the full one is in the spoiler below. Here hash(N, salt) is a deterministic function of the row number and a salt, giving a reproducible number between zero and one. The whole set is deterministic by seed and rebuilds byte for byte.
For those new to this. A seed is the number the generator counts all its "randomness" from. The same seed gives the same world byte for byte, a different seed gives a different world under the same rules. I'll use the term in one more sense later: a training seed is the same kind of number, but for the network's random initial state. Different training seeds mean the same network trained several times from different starting points.
The full world is a hundred and thirty lines and 400 thousand events in 9 seconds. There are five kinds of machine in it, and each one earns its place:
- workstations - one to four accounts of their own, the bulk of the network;
- servers - hundreds of accounts legitimately. Without them the network learns that "many accounts equals anomaly" and drowns in false alarms;
- new machines - no history, everything about them is legitimately new;
- compromised machines;
- machines that are new and compromised at once - otherwise a network trained not to touch legitimate novelty never learns to tell them apart.
Two more mechanisms live on the event rather than on the machine, which is why they aren't in that list. Service accounts travel across many machines, but on each one they appear constantly, so they create no novelty: an account like that can be used by a machine of any kind. And churn - a new employee, a person moving desks, a server that was repurposed. Without it, novelty never occurs in normal activity at all, and the network draws the simple conclusion that "any new event is a compromise".
The whole base world config, all 135 linesThis is the working file straight from the repository, with nothing cut (a copy with Russian comments lives in ru/gen/, the same lines in both), together with the comments I wrote for myself as I went. The comments matter more than the code here: almost every one of them answers "why does this class exist at all" rather than "what does this line do". Those answers were the actual work - the syntax took minutes.
<tdc version="0.1"> <!-- Synthetic world: a network of machines, each with its own login history. Two pools, because the load in a network is distributed very unevenly: a handful of servers sees a huge stream and hundreds of accounts LEGITIMATELY, while workstations are many and each one is quiet. Roles of the work machines: 0 - workstation (its own 1..4 accounts), 2 - NEW host (no history: everything on it is legitimately new), 3 - workstation whose accounts become foreign in the second period, 4 - a machine that is new AND working under foreign accounts AT THE SAME TIME. Role 4 exists because a network trained to stay silent on legitimate novelty has to be taught separately to tell it from foreign novelty: an honest new host has fresh accounts, this one has accounts moved from other machines, that is, long known to the network. Period 0 - history, period 1 - observation. The knobs are spread deliberately wide within a class: the network must learn the class, not a preset. The makeup of the world (share of servers, rarity of new hosts, density of the stream) is calibrated against ROUGH statistics of a real network. The features of the observed phenomenon itself were not calibrated against anything. --> <env count="400000" seed="world-train-1"> <pool name="Work" count="290"> <sequence name="hid"><gen type="increment" value="1"/></sequence> <sequence name="role"><gen type="text" value="0,2,3,4" percent="78.8,0.6,20,0.6"/></sequence> <sequence name="userBase"><gen type="formula" expr="hid * 30"/></sequence> <sequence name="userSpan"><gen type="number" value="1..4"/></sequence> <sequence name="dstBase"><gen type="number" value="1..4500"/></sequence> <sequence name="dstSpan"><gen type="number" value="2..9"/></sequence> <!-- spread over hours: the wider, the rarer the events in a window; some events go past the end of observation - that is how both dense and quiet windows are born --> <sequence name="spread"><gen type="number" value="1..40"/></sequence> <!-- on some machines the makeup does not change for years, on others it flows constantly --> <sequence name="churnMil"><gen type="number" value="0..8"/></sequence> <!-- foreign activity has its own limited circle of accounts and places: the axis is spread WIDE (2..60) so that the network does not memorise one size --> <!-- The SHARE of foreign events among all events of a machine: from 2% to 100%. In a real journal a foreign event can be one in fifty ordinary ones, and a share feature drowns it. The network must see the diluted case, so the axis is spread down to the rarest values. --> <sequence name="fgRateMil"><gen type="number" value="1000..1000"/></sequence> <!-- the circle of foreign accounts: the NUMBERS OF OTHER MACHINES whose accounts are used --> <sequence name="fgBase"><gen type="number" value="1..290"/></sequence> <sequence name="fgSpan"><gen type="number" value="2..60"/></sequence> <sequence name="fgDstBase"><gen type="number" value="1..4900"/></sequence> <sequence name="fgDstSpan"><gen type="number" value="2..70"/></sequence> <sequence name="failMil"><gen type="number" value="20..700"/></sequence> <sequence name="baseMil"><gen type="number" value="1..30"/></sequence> </pool> <pool name="Serv" count="10"> <sequence name="sid"><gen type="increment" value="1"/></sequence> <sequence name="userBase"><gen type="number" value="1..7000"/></sequence> <sequence name="userSpan"><gen type="number" value="60..280"/></sequence> <sequence name="dstBase"><gen type="number" value="1..3000"/></sequence> <sequence name="dstSpan"><gen type="number" value="40..400"/></sequence> <sequence name="spread"><gen type="number" value="15..22"/></sequence> <sequence name="baseMil"><gen type="number" value="1..30"/></sequence> </pool> <sequence name="W"><gen type="pool" value="Work"/></sequence> <sequence name="S"><gen type="pool" value="Serv"/></sequence> <sequence name="N"><gen type="increment" value="1"/></sequence> <!-- a third of the stream goes to the servers: they are few, the stream to each is huge --> <sequence name="IsServ"><gen type="formula" expr="hash(N, 10) < 0.35 ? 1 : 0"/></sequence> <!-- Service and machine accounts: in a real network they log in everywhere, and the reach of such an account is thousands of machines. Without them the world consists of nothing but accounts "tied to a workplace", which never happens in life. --> <!-- Every machine uses the same service accounts (so they create no novelty), and every such account serves several machines - hence the wide reach. A small part (tier 9500+) goes everywhere. --> <sequence name="IsSvc"><gen type="formula" expr="hash(N, 12) < 0.30 ? 1 : 0"/></sequence> <!-- machine name: servers and work machines in different number ranges --> <sequence name="Host"><gen type="formula" expr="IsServ == 1 ? 9000 + S.sid : W.hid"/></sequence> <sequence name="Role"><gen type="formula" expr="IsServ == 1 ? 1 : W.role"/></sequence> <sequence name="Spread"><gen type="formula" expr="IsServ == 1 ? S.spread : W.spread"/></sequence> <!-- a new host lives only in the second period --> <sequence name="Period"> <gen type="formula" expr="Role == 2 || Role == 4 ? 1 : (hash(N, 6) < 0.60 ? 0 : 1)"/> </sequence> <sequence name="Time"> <gen type="formula" expr="Period == 0 ? floor(hash(N, 5) * 28800) : 28800 + floor(hash(N, 3) * Spread) * 3600 + floor(hash(N, 4) * 3600)"/> </sequence> <!-- on this machine the accounts are foreign in the second period --> <sequence name="Foreign"> <gen type="formula" expr="(Role == 3 || Role == 4) && Period == 1 && hash(N, 11) < W.fgRateMil / 1000 ? 1 : 0"/> </sequence> <sequence name="UserBase"><gen type="formula" expr="IsServ == 1 ? S.userBase : W.userBase"/></sequence> <sequence name="UserSpan"><gen type="formula" expr="IsServ == 1 ? S.userSpan : W.userSpan"/></sequence> <sequence name="DstBase"><gen type="formula" expr="IsServ == 1 ? S.dstBase : W.dstBase"/></sequence> <sequence name="DstSpan"><gen type="formula" expr="IsServ == 1 ? S.dstSpan : W.dstSpan"/></sequence> <!-- Legitimate change comes in two kinds, and they look different: a new employee (the account was not in the network at all, range 20000+) and an existing person moving to another machine. Without both the network will not learn to tell legitimate novelty from foreign credentials. --> <sequence name="Churn"> <gen type="formula" expr="Foreign == 0 && IsSvc == 0 && hash(N, 14) < W.churnMil / 10000 ? 1 : 0"/> </sequence> <sequence name="User"> <gen type="formula" expr="Foreign == 1 ? ((W.fgBase + floor(hash(N, 1) * W.fgSpan)) % 290) * 30 + floor(hash(N, 18) * 2) : (IsSvc == 1 ? (hash(N, 13) < 0.25 ? 9500 + floor(hash(N, 1) * 20) : 9000 + (Host * 7 + floor(hash(N, 1) * 6)) % 400) : (Churn == 1 ? (hash(N, 17) < 0.5 ? 20000 + floor(hash(N, 15) * 900) : (floor(hash(N, 15) * 290)) * 30 + floor(hash(N, 19) * 4)) : UserBase + floor(hash(N, 1) * UserSpan)))"/> </sequence> <sequence name="Dst"> <gen type="formula" expr="Foreign == 1 ? 1 + (W.fgDstBase + floor(hash(N, 2) * W.fgDstSpan)) % 4900 : (Churn == 1 ? DstBase + DstSpan + floor(hash(N, 16) * 50) : DstBase + floor(hash(N, 2) * DstSpan))"/> </sequence> <sequence name="FailCut"> <gen type="formula" expr="Foreign == 1 ? W.failMil / 1000 : (IsServ == 1 ? S.baseMil : W.baseMil) / 1000"/> </sequence> <sequence name="Ok"> <gen if="hash(N, 8) < FailCut" type="text" value="Fail"/> <gen type="text" value="Success"/> </sequence> </env> <block> <line><data>${{Time}},U${{User}},C${{Host}},D${{Dst}},${{Ok}},${{Foreign}}</data></line> </block> </tdc> What to look at, if you're skimming. Two pools instead of one - because load in a network is distributed wildly unevenly, and a handful of servers legitimately see hundreds of accounts. Role 4 - a machine that is new and running under foreign accounts at the same time; it exists precisely so that a network trained to stay quiet on legitimate novelty is separately taught to tell honest novelty from stolen. The knobs opened wide - 2..60, 1..40, 20..700: the network has to learn the class, not one particular set of numbers. And Churn - legitimate turnover of two kinds, without which novelty never occurs in normal activity and the network concludes that "any new event is a compromise".
The output line at the very bottom is the entire log format: time, account, machine, destination, success, label. The last field takes no part in training; it is there for the feature builder (the repository calls it the measurer), so it knows the truth.
Two formulas in the file are long and run off to the right - User and Dst. There's no need to scroll through them; they are built the same way, as a ladder of nested conditions: "if foreign, take from the compromise circle; else if a service account, from the service range; else if churn, either fresh or moved in; else its own". One line instead of four branches on the outside; it reads badly, but it is fixed in one place.
The mistake that cost more than every setting
For a long time my "compromised" accounts were non-existent: the generator drew a number from a range, and no such account existed anywhere in the network. Comparing distributions against reality exposed it instantly:
| in labelled windows | reality | synthetic, before the fix |
|---|---|---|
| account known to the network, but on this machine for the first time | 26 | 2 |
| account did not exist in the network at all | 0 | 11 |
In a real network, lateral movement runs on existing accounts - that is the whole point of stealing them. What I was producing wasn't theft but invention, and the network was diligently learning to catch an artefact that never occurs in live data.
On the left, what I was producing at first: the account exists nowhere. On the right, what actually happens: the account is real and works somewhere else
That one fix reduced false alarms fourfold at the same recall. More than every network setting put together.
Synthetic data has to be right in meaning, not similar in statistics.
The network
The first network is fully connected, the most ordinary kind: eighteen features in, eight layers of twelve neurons, 1333 weights. Training takes seconds on a laptop, no GPU. In the table below it appears as "deep [12]x8"; a second one, recurrent, shows up later in the article - its turn comes after the capacity measurement.
The unit of observation is the window, "one machine for one hour". The features come in three sorts: volume and variety (how many events, accounts, destinations), shares of novelty, and the context of the machine (is it a server or a quiet workstation). Two features are worth naming separately, because they are the ones that encode the meaning of stolen credentials:
- moved accounts - the number of events where the account is known to the network but appears on this machine for the first time. The signature of theft: the account exists and works somewhere else;
- fresh accounts - the number of events where the account did not exist in the network at all. The signature of a new employee.
Without that distinction, a new person legitimately starting work is indistinguishable from a compromise.
The counter features are taken under a logarithm, and that isn't cosmetic. The synthetic world has 290 machines, the real one has 17,684. A linear feature would push activations far outside the training range, where a network behaves unpredictably; a logarithm turns a difference of scale into a shift.
The same code computes the features for the synthetic data and for reality. That isn't a declaration: day 8 was recomputed by two independent implementations of the feature builder and compared line by line, and 231,787 lines matched exactly.
On synthetic data with a different seed - that is, on another instance of the same world - the network achieves an AUC of 0.99996. That means exactly one thing, and I wrote the conclusion down before the exam: the network has learned my generator thoroughly. And nothing else.
For those new to this. AUC is the area under the ROC curve. The easiest way to read it: take one labelled window and one ordinary window at random; AUC is the probability that the model ranks the labelled one higher. 1.0 is a perfect ordering, 0.5 is a coin. There is a whole section later on why AUC lies on this problem, but for now let it be "the share of correctly ordered pairs".
How many weights are actually needed
I picked the architecture blindly at first - the classic mistake. Then I measured it: thirty-one configurations, three training seeds each (three trainings from different starting points, so the spread is visible), ninety-three trained networks in 170 seconds on a CPU. The characteristic rows:
| architecture | weights | easy day | hard day |
|---|---|---|---|
| flat [4] | 81 | 0.99868 | 0.94208 |
| flat [8] | 161 | 0.99998 | 0.94151 |
| flat [256] | 5121 | 0.99997 | 0.95646 |
| two steps [128,64] | 10753 | 0.99995 | 0.96318 |
| deep [12]x8 | 1333 | 0.99997 | 0.97518 |
| deep [12]x12 | 1957 | 0.99940 | 0.94923 |
Three conclusions from that.
- Performance on the easy day is saturated: from 161 weights to 10,753 everything sits in the 0.99995-0.99998 range; capacity simply isn't the constraint there.
- Depth beats width: 1333 weights do better than 10,753.
- And too deep is bad too: twelve layers without residual connections drop to 0.949 and spoil even the easy day.
Later I ran nine sequence architectures, the LSTM among them, from dilated convolutions to external memory in the spirit of a neural Turing machine. Half of those names I learned that same evening, so treat this as curiosity rather than a survey. The best turned out to be a plain recurrent network of 4249 parameters - an LSTM, a network that reads one machine's hours in order and remembers what came before. On the hard day it gives 0.990 against 0.975 for the best fully connected one, and from here on in the article "the network" means this one. The worst turned out to be the transformer: self-attention is greedy for data and wants to link distant elements, and our sequences are 24 steps long with nothing in them to link. The full tables are in ARCHITECTURES in the repository.
The first result on real data
The day with the densest exercise activity: 231,787 ...




