Thursday, June 13, 2019

The data science of cyber-squatting


The Apache Metron project has an interesting approach to detecting cyber-squatters that can be found here. Briefly, it generates lots of names that are similar to your domain using a tool called DNS Twist.

They then use Bloom Filters to check each domain name coming through in the stream is not on this black-list. Bloom Filters cannot say that something definitely is in a collection but can say when something definitely is not. The trade-off is that they consume very little memory. And for this use case, we're not interested whether our domain is not on a black-list, only whether it is.

Bloom Filters do this by using k algorithms that given the data generate a number between 0 and m. All of these numbers then becomes indices in an m-sized bit map and we flip that but to 1. Now, we have some new data come through and we ask, have we seen this before? Well, we run those same k algorithms and if each one gives us and index that has 0 in the corresponding array, we know that this datum is definitely not on our black-list.

My approach was different. Given the domains we own (over 900  of them), I generate n-grams of characters and create a distribution of their frequency. For me, n for is {2,3} but your mileage may vary.

The same calculation is performed on each domain as it flies by and compared (using Jensen-Shannon) with the 900 or so distributions representing our real estate. A score of 0 obviously means that it's one of ours. A score much greater means it's very different. But a score close to 0 is worrying.

How close before you raise alarms depends on your data and your tuning. During testing, the smallest, non-zero domain was "www-OUR_DOMAIN" rather than "www.OUR_DOMAIN". This was a known spear-phishing scam so clearly our algorithm works to some degree. What's more, DNS Twist does not seem to have anticipated this attack.

Friday, June 7, 2019

Architecting the Cloud


We're taking the plunge and moving a SOC to Google's cloud. This is the proposed architecture. With von Moltke's warning of "no plan survives first contact with the enemy" in mind, I write this for posterity and we shall see how well it stands up to reality.

Architecture

Ingestion

Tools: Apache Kafka.

Why? Because Kafka (unlike Google's PubSub) is not tied to a particular cloud provider. What's more, it's the de facto standard for high volume streams of data and finding developers who know it is not hard.

Storage

Tools: Google's BigQuery and DataProc.

Why? Google will worry about the long term storage of the data in BigQuery and we will pull subsets of it out into DataProc (which is basically Apache Spark, YARN and Hadoop) for analysis. This is Google's recommend way of doing it although one must bear in mind that they are trying to sell something. This basically makes BigQuery the golden source of data and DataProc the data lake.

Visualisation

Tools: Google's DataLab (aka, Jupyter) and some DataProc components (specifically, Apache Zeppelin)

Why? Jupyter and Zeppelin are open source. Zeppelin integrates very nicely with Spark, er, I mean Google DataProc; and Jupyter integrates easily with BigQuery.


Potential Use Cases

The Senior SOC Analyst - Investigation

A sufficiently sophisticated analyst should have no problem writing Jupyter notebooks that access BigQuery directly using plain SQL. They may need some familiarity with Python if they then wish to present this data.

The Junior SOC Analyst - Investigation

Google does not appear to have a managed Elastic component. However, plain old Linux instances can be spun up and Elastic installed on them manually. This Elastic cluster can then batch load data that was extracted from BigQuery. Alternatively, it could stream from the Kafka topic used in the ingestion layer. Either way, the analyst then can run free-text searches over the event data rather than using the more complicated SQL and Python languages.

The Data Scientist - Modelling

Given a requirement to build a deliverable, a data scientist can spin up an appropriately sized DataProc cluster and transfer data from BigQuery. BQ will be his data lake.
"Simply bringing data from various parts of a large organization together in one place is valuable because it enables joins across datasets that were previously disparate ... collecting data in its raw form, and worrying about schema design later, allows the data collection to be speeded up (a concept sometimes known as a data lake or enterprise data hub... Simply dumping data in its raw form ... has been dubbed the sushi principles: raw data is better". [1]
So, imagine that the data scientist is looking at anomalous employee behaviour. He can pull in large amounts of data from BigQuery in an arbitrary format. He can also pull in the employee database and marry the two together in DataProc. By itself, the BigQuery data won't necessarily tell you that an SSH connection is suspicious. However, coupled with employee data, it would be highly suspicious if that SSH connection came from the machine of a 52-year old HR lady rather than a 25-year old sysadmin.

The Data Scientist - Producing Artefacts

Google's Spark/Hadoop/YARN offering (DataProc) only holds ephemeral data. When the cluster is no longer needed, it can be closed down and the data will disappear into the ether but not before the Data Scientist has finished building his model. This model will be distilled from the petabytes in BigQuery and the terabytes in DataProc and can be brought out of the cloud inexpensively in the form of a moderately sized (megabytes?) artefact.

The Developer - Writing Parsers

Feeds into the ingestion system can be of disparate formats (CEF, Rsyslog etc). Therefore, developers must be able to write new parsers and deploy them. It makes sense to run these parsers on the ingestion layer rather than on all the agents to avoid deployment faff.

The Developer - Analysing Streams

Since analysts want to be warned of high-risk events as soon as possible, there will be a need for the developers to write stream monitoring software. This may listen to the same Kafka topic used in ingestion. Perhaps it can use Google's PubSub to send the warnings if it lives in the cloud.

The Developer - Using ML Models in Production

It is to be hoped that artefacts generated by the data scientists can be deployed in production. These artefacts should be written in such a way that they do not need to access the data in GCP. For instance, a data scientist might train a neural net in GCP to spot anomalous behaviour then, when happy with it, give it to the developer (preferably as a JAR) to use in their stream monitory software.


Schema

The ingestion layer can do the following to the data before feeding it into BigQuery:
  1. Do no parsing and put it into BigQuery raw.
  2. Partially parse it.
  3. Parse it completely.
Each has pros and cons.

No Parsing

This is the simplest path initially. It means all data lives in the same table irrespective of which stream it came from. But it also means that BigQuery will have no schema to speak of, the classic pattern of "schema-on-read [where] the structure of the data is implicit, and only interpreted when the data is read" [1].

This will make querying and data extraction extremely difficult as the table will look a little something like this:

IngestTimestamp  Value
Thu  6 Jun 16:44:01.123 BST 2019{ "additionalEventData": { "SSEApplied": "SSE_S3", "x-amz-id-2": "u8yUG+gwxxc3ovA49cFbD9XFLyHRBJAkwnHPeFZVg4AnfGKp30LXiQ2oAY3BUX5Lwflkijiaq9M=" }, "awsRegion": "eu-west-1", "eventID": "e4490a9a-1be4-4585-98ee-9d0a4e59fcc3", "eventName": "PutObject", "eventSource": "s3.amazonaws.com", "eventTime": "2018-12-05T14:47:26Z", "eventType": "AwsApiCall", "eventVersion": "1.05", "readOnly": false, "recipientAccountId": "479626555249", "requestID": "770130FED723D6EA", "requestParameters": { "bucketName": "vsecacloudtraillogs", "key": "AWSLogs/479626555249/CloudTrail/eu-west-1/2018/12/05/479626555249_CloudTrail_eu-west-1_20181205T1450Z_apgVO8lFdNcrVfHn.json.gz", "x-amz-acl": "bucket-owner-full-control", "x-amz-server-side-encryption": "AES256" }, "resources": [ { "ARN": "arn:aws:s3:::vsecacloudtraillogs/AWSLogs/479626555249/CloudTrail/eu-west-1/2018/12/05/479626555249_CloudTrail_eu-west-1_20181205T1450Z_apgVO8lFdNcrVfHn.json.gz", "type": "AWS::S3::Object" }, { "ARN": "arn:aws:s3:::vsecacloudtraillogs", "accountId": "479626555249", "type": "AWS::S3::Bucket" } ], "responseElements": { "x-amz-server-side-encryption": "AES256" }, "sharedEventID": "bfa6ef38-d494-4974-8d76-1c07b305ae90", "sourceIPAddress": "192.168.0.1", "userAgent": "cloudtrail.amazonaws.com", "userIdentity": { "invokedBy": "cloudtrail.amazonaws.com", "type": "AWSService" } }
Thu  6 Jun 16:44:01.124 BST 2019{ "region": "us-east-1", "detail": { "type": "UnauthorizedAccess:EC2/SSHBruteForce", "resource": { "resourceType": "Instance", "instanceDetails": { "instanceId": "i-99999999", "instanceType": "m3.xlarge", "launchTime": "2016-08-02T02:05:06Z", "platform": null, "productCodes": [ { "productCodeId": "GeneratedFindingProductCodeId", "productCodeType": "GeneratedFindingProductCodeType" } ], "iamInstanceProfile": { "arn": "GeneratedFindingInstanceProfileArn", "id": "GeneratedFindingInstanceProfileId" }, "networkInterfaces": [ { "ipv6Addresses": [], "networkInterfaceId": "eni-bfcffe88", "privateDnsName": "GeneratedFindingPrivateDnsName", "privateIpAddress": "10.0.0.1", "privateIpAddresses": [ { "privateDnsName": "GeneratedFindingPrivateName", "privateIpAddress": "10.0.0.1" } ], "subnetId": "Replace with valid SubnetID", "vpcId": "GeneratedFindingVPCId", "securityGroups": [ { "groupName": "GeneratedFindingSecurityGroupName", "groupId": "GeneratedFindingSecurityId" } ], "publicDnsName": "GeneratedFindingPublicDNSName", "publicIp": "127.0.0.1" } ], "tags": [ { "key": "GeneratedFindingInstaceTag1", "value": "GeneratedFindingInstaceValue1" }, { "key": "GeneratedFindingInstaceTag2", "value": "GeneratedFindingInstaceTagValue2" }, { "key": "GeneratedFindingInstaceTag3", "value": "GeneratedFindingInstaceTagValue3" }, { "key": "GeneratedFindingInstaceTag4", "value": "GeneratedFindingInstaceTagValue4" }, { "key": "GeneratedFindingInstaceTag5", "value": "GeneratedFindingInstaceTagValue5" }, { "key": "GeneratedFindingInstaceTag6", "value": "GeneratedFindingInstaceTagValue6" }, { "key": "GeneratedFindingInstaceTag7", "value": "GeneratedFindingInstaceTagValue7" }, { "key": "GeneratedFindingInstaceTag8", "value": "GeneratedFindingInstaceTagValue8" }, { "key": "GeneratedFindingInstaceTag9", "value": "GeneratedFindingInstaceTagValue9" } ], "instanceState": "running", "availabilityZone": "GeneratedFindingInstaceAvailabilityZone", "imageId": "ami-99999999", "imageDescription": "GeneratedFindingInstaceImageDescription" } }, "service": { "serviceName": "guardduty", "action": { "actionType": "NETWORK_CONNECTION", "networkConnectionAction": { "connectionDirection": "INBOUND", "remoteIpDetails": { "ipAddressV4": "127.0.0.1", "organization": { "asn": "-1", "asnOrg": "GeneratedFindingASNOrg", "isp": "GeneratedFindingISP", "org": "GeneratedFindingORG" }, "country": { "countryName": "GeneratedFindingCountryName" }, "city": { "cityName": "GeneratedFindingCityName" }, "geoLocation": { "lat": 0.0, "lon": 0.0 } }, "remotePortDetails": { "port": 32794, "portName": "Unknown" }, "localPortDetails": { "port": 22, "portName": "SSH" }, "protocol": "TCP", "blocked": false } }, "resourceRole": "TARGET", "additionalInfo": { "sample": true }, "eventFirstSeen": "2018-05-11T14:56:39.976Z", "eventLastSeen": "2018-05-11T14:56:39.976Z", "archived": false, "count": 1 }, "severity": 2, "createdAt": "2019-06-06T16:50:11.441Z", "updatedAt": "2018-05-11T14:56:39.976Z", "title": "127.0.0.1 is performing SSH brute force attacks against i-99999999. ", "description": "127.0.0.1 is performing SSH brute force attacks against i-99999999. Brute force attacks are used to gain unauthorized access to your instance by guessing the SSH password." } }

That is, all the data in one, big varchar if we were to use RDBMS parlance. Note how the table does not care whether the event is GuardDuty or a CloudTrail. Also note that although in this example it happens that GuardDuty and CloudTrail are JSON and a schema can be inferred, not all streams are going to be in the JSON format. In any case "if your BigQuery write operation creates a new table, you must provide schema information" (from the docs).

So, how would you write a query to, say, pull out a record with a given invokedBy field? And how would you limit that query to just a subset of the data (to reduce processing costs)?

It appears that BQ allows UDFs (user defined functions) but they're written in JavaScript. If you're going to write a parser, would you rather write and test it locally in a JVM language or in JS running on an opaque technology stack?

Partial Parsing

The idea here is to "lightly" parse the data. The fields that are common over all streams are event time, machine name and stream name - that is all (this appears to be what the Apache Metron team have gone with. "We are currently working on expanding the message standardization beyond these fields, but this feature is not yet available" - from here).

These parsed fields can be pulled out into their own columns but the payload must remain a string. So, like the solution above (No Parsing), analysts will still find it hard to grok the data if they access it directly via BQ.

However, things may change upon pulling the data from BQ to, say DataProc. In this case, only what is needed (given time range, machine and/or stream type) will be pulled out and bespoke parsers can be written that pull only the data needed for the query. This will require far less development work than the Complete Parsing solution below but will require an increased level of sophistication from the analysts.

Complete Parsing

This requires writing (or using open source) parsers for a particular feed and then mapping it to a rich schema in BigQuery. This will require a lot of up-front development work but it results in full, rich SQL queries than can be run by the analysts.

This solution would probably lead to a different table per event stream type as they all represent different data. If you were feeling particularly ambitious, you could try to unify all this data into one table. However, the history of attempting to unify all types of security events into one schema has been a sorry tale. Rafael Marty, who has spent his career trying to do exactly this says:
"For over a decade, we have been trying to standardize log formats. And we are still struggling. I initially wrote the Common Event Format (CEF) at ArcSight. Then I went to Mitre and tried to get the common event expression (CEE) work off the ground to define a vendor neutral standard. Unfortunately, getting agreement between Microsoft, RedHat, Cisco, and all the log management vendors wasn’t easy and we lost the air force funding for the project. In the meantime I went to work for Splunk and started the common information model (CIM). Then came Apache Spot, which has defined yet another standard (yes, I had my fingers in that one too). So the reality is, we have 4 pseudo standards, and none is really what I want. I just redid some major parts over here at Sophos (I hope I can release that at some point)."
So, only the brave, foolhardy or well-funded need apply.

Schema evolution

Note that it seems that the scope for schema evolution is limited in BigQuery which "natively supports [only] the following schema modifications: Adding columns to a schema definition; Relaxing a column's mode from REQUIRED to NULLABLE" (from the docs).

This means that having a rich schema (as in Complete Parsing) that is easy to interrogate is unfortunately very brittle. You must have your schema pretty much nailed down on your first attempt or else you may very well be forced to introduce hacks further down the line as you shoe-horn changes into it.

Which approach is best?

"Enforcement of schemas in database is a contentious topic, and in general there's no right or wrong answer." [1]

Conclusion

I'm firmly in the camp that says the architecture document should come at the end of an MVP not the beginning. It must adapt to the hidden realities that become apparent along the way. I'll revisit this post in the future to see what actually happened in our case.

We started with a military quote and we'll end with one, this time from Eisenhower: "In preparing for battle I have always found that plans are useless, but planning is indispensable."

[1] Designing Data Intensive Applications, Klepperman

Wednesday, June 5, 2019

Google Cloud Analytics - Notes


The Google Cloud ecosystem is a sprawling conurbation of tools. These are some notes I made that helped me remember.

One sentence summaries

DataProc - Spark, Hadoop, YARN.

DataProc components - Tools that compliment DataProc including Apache Zeppelin.

DataLab - Jupyter notebooks. Not to be confused with DataStudio which is for ad campaigns etc. DataLab can integrate with DataProc (see Google's documentation)

BigTable - Basically, HBase.

DataStore - Basically, a BLOB store.

PubSub - Messaging.

BigQuery - Basically, a SQL engine behind a REST API.

Cloud SQL - MySQL or PostrgresSQL.

Cloud Dataflow - Apache Beam.

Composer - Apache Airflow.

Google SDK - has emulators to let you run locally. Emulators are limited to BigTable, DataStore, FireStore and PubSub.

Stackdriver - think JConsole mixed with Splunk. "gives you access to logs, metrics, traces, and other signals from your infrastructure platform(s), virtual machines, containers, middleware, and application tier, so that you can track issues all the way from your end user to your backend services and infrastructure" (documentation).

Prepare your Environment

Unlike Amazon, you can quite easily do a lot of the admin from your local machine. On my home Ubuntu box, I ran:

export CLOUD_SDK_REPO="cloud-sdk-$(lsb_release -c -s)"
echo "deb http://packages.cloud.google.com/apt $CLOUD_SDK_REPO main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -
sudo apt-get update && sudo apt-get install google-cloud-sdk
sudo apt-get google-cloud-sdk-datalab
sudo apt-get install google-cloud-sdk-datalab
sudo apt-get install kubectl
gcloud init
sudo apt-get install docker

and had everything I needed to play.

Running DataLab locally

One very nice feature is that you can run pieces of Google's infrastructure locally.

export  IMAGE=gcr.io/cloud-datalab/datalab:latest
export  PROJECT_ID=$(gcloud config get-value project)
if [ "$OSTYPE" == "linux"* ]; then   PORTMAP="127.0.0.1:8081:8080"; else PORTMAP="8081:8080"; fi
docker pull $IMAGE
docker run -it -p $PORTMAP  -v "https://github.com/PhillHenry/googlecloud.git"  -e "PROJECT_ID=philltest"  $IMAGE
.
.
Open your browser to http://localhost:8081/ to connect to Datalab.

Note that not all components can be run locally. Significantly, BigQuery cannot.

Friday, April 12, 2019

Zeppelin 1


Introduction

When I'd tinkered with Zeppelin in the past, it seemed a bit buggy but using zeppelin-0.8.1-bin-netinst has been reasonably straightforward.

The reason we want to get it into my current project is that the analysts want to play with and visualise the data without getting to close to Spark.

Configuring

I had to add dependency:

org.apache.commons:commons-lang3:3.5

to the Spark interpreter (see StackOverflow) because I was getting:

Caused by: java.io.InvalidClassException: org.apache.commons.lang3.time.FastDateParser; local class incompatible: stream classdesc serialVersionUID = 2, local class serialVersionUID = 3

when trying to call Spark. I also added the properties:

HADOOP_CONF_DIR
HADOOP_HOME   
SPARK_HOME

to the interpreter config in the web admin GUI and

export MASTER=yarn-client
export HADOOP_CONF_DIR=/media/sdb8/Tools/Cluster/Hadoop/Current/etc/hadoop/
export SPARK_HOME=/media/sdb8/Tools/Cluster/Spark/Current/

to ZEPPELIN_HOME/conf/zeppelin-env.sh.

I also set up Hadoop again (following my own advice here) but this time I had to unexpectedly tweak the config as my secondary name node was trying to contact 0:0:0:0. It required defining dfs.namenode.secondary.http-address in hdfs-site.xml (see StackOverflow) and my problem went away.

Running

Gratifyingly, I got:


To add your own code to the classpath by setting spark.jars to your uber-jar (see the documentation).

Lo and behold, we can now use Zeppelin to ask Spark to calculate trigrams on a corpus using code I'd written elsewhere:


Note, to then use PySpark and all the libraries you've installed with pip, you'll want to set the zeppelin.pyspark.python property in the Spark interpreter (not the zeppelin.python property in the Python interpreter) and un-tick zeppelin.pyspark.useIPython. Otherwise, all those Python libraries you've installed won't be found.

Addendum (23/5/2019)

After a few weeks of playing with Zeppelin, I'm still pretty impressed despite a few glitches in the GUI. However, it's still annoying that I cannot push/pull to a Git repository. And I got bit by this (StackOverflow) too,

It's also worth mentioning that if you're running Zeppelin on a headless server (like AWS), you will need to follow the instructions here if you want some of Python's wonderful graphics libraries.

Thursday, April 4, 2019

Master of your domain name


Introduction

I've had moderate success using neural nets to distinguish between good domain names and domain names generated by an algorithm, something you often find in malware. However:

  1. Tuning neural nets is hard and requires esoteric knowledge.
  2. They're computationally expensive both to train (OK) and to use (not OK).
  3. The results I have so far are at best reasonable and no better.

So, using the same raw data I used in my neural net work, I tried to do better.

Jensen-Shannon, bigram population distribution

I used the SMaths library to create the Jensen-Shannon scores comparing the character distribution for both good and bad domain names against the distribution for all good names using 1-hot character encoding. Unfortunately, the results are poor:

JS scores for good (green) and bad (red) domain names

Just Shannon Entropy

What do you do when there the probability for an n-gram is zero? The common answer is "ignore the zero probabilities, and carry on summation using the same equation" (StackExchange).

But I found penalizing zeros gave me better powers to differentiate the two categories. The value of this penalty was empirically derived but roughly optimising it with a crude binary search gave me a much better spread of the distributions:

Penalized Shannon entropy scores for good (green) and bad (red) domain names
and consequently a great ROC curve:

The Data

There is, however, a sting in the tail. Some of the good domain names appear to be bad!

If we look at that ROC curve in 3d:
The same ROC curve in 3d showing the threshold's relationship with the curve
And we estimate that the best value for our threshold is roughly -0.018 to maximise catching bad domains with the minimum disruption from good domains that just look a bit suspicious.

Looking at the false negatives was illuminating. Here is a small sample:

dynserv
bowupem
ikrginalcentricem
gentlemanwritten
qmigfordlinnetavox
jfbjinalcentricem
osbumen
vxheellefrictionlessv

These show a level of sophistication greater than those generated by a DGA. They contain normal (or normal-sounding) words. Words like frictionless appear in a number of these domains, adding an air of authenticity. Indeed, gentlemanwritten.net sounds positively respectable.

The false positives were even more interesting. Here is a very small selection:

95a49f09385f5fb73aa3d1e994314a45b8d51f17
mhtjwmxf
wlhzfpgs
kztudyya

These look awfully suspicious. In fact, attempts to look them up with whois reveals nothing at all - which is odd.

Conclusion

Although we're getting better results with a less computationally expensive solution than neural nets, we always assumed that the training data was clean. In fact, it appears to be reasonably clean but not pristine. On the upside, this means that if we clean the data we can reasonably expect our false positive rate to go down even further.


Saturday, March 30, 2019

FP and Big Data part 2


Functional Programming gives guarantees that Big Data tools can leverage. For example, in the event of an aggregation, "such as SUM, expressible as an associative and commutative operator (who said “monoid”?), it can be executed more efficiently, e.g. the framework can push some aggregation into the first map. In Hadoop, such an aggregation is called a combiner." (Eugene Kirpichov at Medium.com)

Note that Spark's [SO] reduce (and indeed Scala's too) is over commutative semigroups when it should be on just semigroups. "The class of commutative semigroups consists of all those semigroups in which the binary operation satisfies the commutativity property that ab = ba for all elements a and b in the semigroup." (Wikipedia).

[In Scala, "reduceLeft and foldLeft apply the operator from left to right (head to tail). reduceRight and foldRight apply it from right to left. As for reduce and fold, the order is unspecified, which is what allows to have parallel implementations for it (see ParIterableLike). As a consequence, you'd better be sure that your operator is associative [and commutative - think String concatenation] when calling reduce, otherwise the result will not be deterministic." (StackOverflow)]

This clearly is not true for some obvious semigroups (for instance, string concatenation with the empty string being the "zero" element). But it's not necessarily true for Java primitives either.

Haskell doesn't have an issue with floating point folding:

$ ghci
GHCi, version 7.10.3: http://www.haskell.org/ghc/  :? for help
Prelude> foldr (\x y -> x + y) 0 [1.0, 0.05, 0.05]
1.1
Prelude> foldl (\x y -> x + y) 0 [1.0, 0.05, 0.05]
1.1

Whereas, Scala (due to the IEEE754 standard) gives this:

    val floats = Seq(1.0f, 0.05f, 0.05f)
    println(floats.sum)                 // 1.0999999
    println(floats.reduce(_ + _))       // 1.0999999
    println(floats.reduceLeft(_ + _))   // 1.0999999
    println(floats.reduceRight(_ + _))  // 1.1

And it's not just floating point arithmetic that has this problem. If you exceed the maximum value for the Integer and Long, you also will suffer misery.

So be careful. Certain FP rules are not enforced in the JVM.

Friday, March 29, 2019

Hands on tuning a neural net


Introduction

Once again, I am trying to find what a typical domain name is using machine learning. For this, I use one million domains that Cisco helpfully have made available here. Once I have this, I can then use known malicious domains (provided by Bambenek Consulting here) to see how they deviate.

The data

I encode my domains first by creating a histogram of character pairs and turning this into probabilities. Now, each data point is a sparse vector of length 1444 (38 x 38 legitimate characters used in domain names).

Now, I fire up a Variational Autoencoder written using the DL4J framework.

Rig


I have an old GeForce GTX 650 GPU that has only 1gb of memory. "To be blunt, 1GB is really tiny by today's standards, it's not really enough for deep learning especially if you're running a display on it at the same time (OS will use some of the memory)" Alex Black, DL4J Gitter room (Jan 29 00:46 2019)

So, I ran the neural net on the CPU.

I run my code with:

export D=/media/sdb8/Data/CiscoUmbrella/MyData/Real/Results/`date +%d%m%y%H%M` ; mkdir -p $D ; export MAVEN_OPTS="-Xmx32g" ;  taskset 0xFFFCE mvn clean install -DskipTests exec:java -Dexec.mainClass=XXX  -Dexec.args="YYY" | tee $D/mvn.log ; shutter --window=.*firefox.* -o $D/overview.png -e

Note taskset which uses all cores but one (15 out of my 16) so the OS is not overwhelmed.

Also, note shutter which takes a picture of the DL4J console that I am running in Firefox.

Learning Rate

On the "Update: Parameter Ratio Chart" tab, the mean magnitude means "the average of the absolute value of the parameters or updates at the current time step". From the DeepLearning4J visualisation guide:
The most important use of this ratio is in selecting a learning rate. As a rule of thumb: this ratio should be around 1:1000 = 0.001. On the log10 chart, this corresponds to a value of -3. Note that is a rough guide only, and may not be appropriate for all networks. It’s often a good starting point, however. 
If the ratio diverges significantly from this (for example, > -2 (i.e., 10-2=0.01) or < -4 (i.e., 10-4=0.0001), your parameters may be too unstable to learn useful features, or may change too slowly to learn useful features. To change this ratio, adjust your learning rate (or sometimes, parameter initialization). 
With a learning rate of 1e-2 using activation function TANH, this chart was very jagged for me. Changing the learning rate (with Adam) to 1e-4 smoothed it with:
Learning rate 1e-4, activation functions TANH
The learning rate seems to be quite sensitive. With a value of 1e-3, performance is already degrading. Note the Parameter Ratios are touching -2 and above and becoming volatile:
TANH; learning rate 1e-3
The same is true of 1e-5 where this time the parameters are drifting below -4:

TANH; learning rate 1e-5
So, it seems at least for TANH that a learning rate of 1e-4 is the sweet spot.

But with LEAKYRELU, Parameter Ratios started touching -4 and then we started seeing NaNs again. Similarly, for SOFTPLUS, even a learning rate of 1e-5 wasn't enough to save us from NaNs:
SOFTPLUS; learning rate = 1e-5
This came as something of a surprise as LEAKYRELU is often said to be the default activation function.

What's more, my score sucked:

VAE's score with vectors of character bi-gram probabilities
We'll return to the choice of activation function later. In the meantime...

The Data (again)

Maybe representing my data as vector probabilities was not a great idea. So, let's try 1-hot encoding the characters:

VAE's score with 1-hot encoded characters
Ah, that's better (if far from optimal). It's also slower to train as the vectors are just as sparse but now have a size of about 4000.

Reconstruction Distribution

But wait - if the data has changed in its nature, maybe we need to re-asses the neural net. Well, the reconstruction distribution was a Gaussian which might have made sense when we were dealing with probability vectors. However, we're now 1-hot encoding so let's change it to a Bernoulli since "The Bernoulli distribution is binary, so it assumes that observations may only have two possible outcomes." (StackOverflow). Well, that sounds reasonable so let' see:

VAE score with 1-hot encoding, now using Bernoulli reconstruction distribution.
Negative scores? Time to ask the DL4J Gitter group:
PhillHenry @PhillHenry Mar 22 16:25Silly question but I assume a negative "Model Score vs. Iteration" in the "DL4J Training UI" is a bad sign...?
Alex Black @AlexDBlack 00:32@PhillHenry as a general rule, yes, negative score is badit is typically caused by a mismatch between the data, loss function and output layer activation functionfor example, something like using tanh activation function for classification instead of softmax
PhillHenry @PhillHenry 03:02Are there any circumstances where it's OK? (BTW I'm trying to run a VAE).
Samuel Audet @saudet 03:34IIRC, this happens with VAE when it's not able to fit the distribution well
Alex Black @AlexDBlack 04:14VAE is an exception, to a degreeyou can still have a mismatch between the reconstruction distribution and the actual data, and that will give you rubbish negative results (like incorrectly trying to fit gaussian data with bernoulli reconstruction distribution)however, it is possible (but rare) for negative log likelihood to be negative on very good fit of the data - IIRC we exclude some normalization terms. if that's the case, you should see excellent reconstructions though
Well, I've already switched to Bernoulli so what's going wrong?

The Data (yet again)


Scratching my head over a coffee, it struck me. I was still standardising the data as if it were Gaussian! Not sure how a unit test could have saved me here but OK, let's remove the standardisation since why would you standardise a Bernoulli distribution?
VAE score with LEAKYRELU
Wow, that looks almost normal. 

The Activation Function

Since we've changed an awful lot, now might be a good time to try to ditch TANH and try LEAKYREUL again. Sure enough, we don't see any NaNs and the score looks good.

Let's see what the ROC curve looks like with my test data:
ROC curve for VAE with LEAKYRELU, 1-hot encoding, Bernoulli reconstruction
OK, not amazing but it's starting to look better than a monkey score. But how do we make things better?

Architecture


Let's start with the architecture of the net itself.

"Larger networks will always work better than smaller networks, but their higher model capacity must be appropriately addressed with stronger regularization (such as higher weight decay), or they might overfit... The takeaway is that you should not be using smaller networks because you are afraid of overfitting. Instead, you should use as big of a neural network as your computational budget allows, and use other regularization techniques to control overfitting." (Andrej Karpathy)

"How many hidden layers are required to see an improvement where a shallow net underperforms is anyone's guess, but in general more depth would be better - you get more abstract, more general solutions. In practice though, optimization isn't quite so neat, and adding capacity adds risks of the process falling into various pitfalls - local minima, overfitting... And of course then there's the added computational cost." [StackOverflow]

So, let's try an architecture with layers of size [3002, 1200, 480, 192, 76, 30]:

VAE with 5 hidden layers


Here (StackOverflow) is an example of where the latent space is just too big causing overfitting. "You could compress the output further... Autoencoders do exactly that, except they get to pick the features themselves...  So what you do in your model is that you're describing your input image using over sixty-five thousand features. And in a variational autoencoder, each feature is actually a sliding scale between two distinct versions of a feature, e.g. male/female for faces...  Can you think of just a hundred ways to describe the differences between two realistic pictures in a meaningful way? Possible, I suppose, but they'll get increasingly forced as you try to go on.  With so much room to spare, the optimizer can comfortably encode each distinct training image's features in a non-overlapping slice of the latent space rather than learning the features of the training data taken globally."

The takeaway point is not to make the latent space layer too big. We deliberately want to squeeze the data through a small gap. So, let's really squeeze this data with an architecture like [2964, 1185, 474, 189, 75, 2]

VAE with 5 hidden layers and the last layer has 2 units
Hmm, no change and the same happened with final units of 5, 10 and 20. So, this looks like a dead-end.

Also, those weights are going crazy...

Weights

"For NNs though, what you want out of your deep layers is non-linearity. That's where the magnitudes of weights start to matter.

"In most activation functions, very small weights get more or less ignored or are treated as 'evidence against activation' so to speak.

"On top of that some activations, like sigmoid and tanh, get saturated. A large enough weight will effectively fix the output of the neuron to either the maximum or the minimum value of the activation function, so the higher the relative weights, the less room is left for subtlety in deciding whether to pass on the activation or not based on the inputs." [StackOverflow]

Regularization

"A model with large weights is more complex than a model with smaller weights. It is a sign of a network that may be overly specialized to training data. In practice, we prefer to choose the simpler models to solve a problem (e.g. Occam’s razor). We prefer models with smaller weights... The L2 approach is perhaps the most used and is traditionally referred to as “weight decay” in the field of neural networks." (MachineLearningMastery)

However, massively increasing the L2 score made the score worse and no amount of tuning seemed to change that. I was hoping to clip excessive activations but adding RenormalizeL2PerParamType didn't seem to make any difference either.

Well, since adding more layers can be responsible for overfitting, let's now take them away. With merely two hidden layers of size [1003, 401] the weights look more respectable.
VAE with 2 hidden units
The Data (last time)

Finally, I went back to the domain names yet again and stripped them not just of the Top Level Domains but also any preceding text. So, for instance, if the domain name is javaagile.blogspot.com it is mapped to simply blogspot.

Gratifyingly, the the score went from lingering at about 100 down to 40 and the ROC curve now looks decent:


Things learned

- Unit test your data munging code.

- Keep your logs. Print out your hyper-parameters so you capture this in the logs.

- Shuffle your data! I wasted a day testing the neural net on a subset of data but this subset was sorted! Therefore, only the top 10000 data points were being used. You might find the Linux command, shuf, useful for doing this.

- Write tests that test your results, making sure they're sensible

Goldilocks

Getting the best out of the neural net seems to be a combination of tuning:
  • The data that best represents your problem. This is perhaps the most important.
  • The hyper-parameters.
  • The architecture.
As an aside, take a look at TensorFlow playground. It's a great way to get an intuitive feel for what is going on. It's just a neural net that runs in your browser.