Wednesday, May 14, 2025
Interpreting XGBoost models
Monday, April 14, 2025
Interpreting GLMs
Wednesday, February 9, 2022
Generalized Linear Models
GLMs provide a unified framework for modeling data originating from the exponential family of densities which include Gaussian, Binomial, and Poisson, among others. It is defined in [1] as:
"A generalised linear model (or GLM) consists of three components:
- A random component, specifying the conditional distribution of the response variable Yi (for the ith of n independently sampled observations).
- The linear predictor - that is a linear function of regressors:
ηi = α + β1Xi1+ β2Xi2+ ... - A smoothe and invertible link function, g, which converts the expectation of the response variable, μi ≡ E(Yi), to the linear predictor" [see 2]. So:
g(μi) = g(E[Yi]) = ηi
Note that the linear predictor allows for interaction effects, where one variable depends on another and vice versa, or curvilinear effects (ie powers of x terms), etc. Note that the right hand side represents a linear combination of the explanatory variables.
The key to understanding this is by asking: what is an exponential family? The probability mass function would look like this:
f(x|β) = h(x) e(T(x)g(β)-A(β))
where x and β are the data and parameters respectively, and h, g, T, and A are known functions. Now, the thing is that you can shoe-horn a few distributions into this form. Take the binomial distribution you were taught at high school:
f(k, n, p) = nCk pk(1 - p)n-k
with a bit of basic algebra (try it!), you can get it to look like:
f(x|p) = nCk e(x log[p/(1-p)] – n log(1-p))
Hey, that's the form of the exponential family! Here, we've set p=k/n and
g = log[p/(1-p)]
Interestingly, point 3 (above) says this function for g equals our linear predictor in 2, above, and you can derive the sigmoid/logit function (try it!).
Note that this is not why we use the logit function in linear regression. Often, the argument is that it must map the linear predictor that ranges from ±∞ to [0,1] as we're interested in probabilities. Although logit does this, there are an infinite number of equations that do also. So, here is a link that details why only logit can be the only suitable function.
Now, remember that a Bernoulli distribution is a just a binomial when n=1, so this completely describes the case for logistic regression. This is why when we're looking at a model with a binary response, we tell the GLM to use the Binomal family (see, for instace, here in the Spark docs). What we do for other use cases I shall deal with in another post.
[1] Applied Regression Analysis & Generalized Linear Models, Fox, 3rd edition.
Friday, December 3, 2021
More Logistic Regression
Log-Likelihood
How good is our model? I'm told the best way to think about the likelihood in a logistic regression is as the probability of having the data given the parameters. Compare this to what logistic regression is actually doing: giving the most probable parameters given the data.
First, some terminology:
Exo and Endo
"If an independent variable is correlated with the error term, we can use the independent variable to predict the error term, which violates the notion that the error term represents unpredictable random error... This assumption is referred to as exogeneity. Conversely, when this type of correlation exists, which violates the assumption, there is endogeneity." - Regression Analysis, Jim Frost.
"An endogenous variable is a variable in a statistical model that's changed or determined by its relationship with other variables within the model. In other words, an endogenous variable is synonymous with a dependent variable, meaning it correlates with other factors within the system being studied. Therefore, its values may be determined by other variables. Endogenous variables are the opposite of exogenous variables, which are independent variables or outside forces. Exogenous variables can have an impact on endogenous factors, however." [Investopedia]
Spark
Unfortunately, Spark does not seem to have a calculation for log-likelihood out of the box. This forced me to code my own. I looked at the code in the Python library, StatsModels, and converted it to PySpark.
Comparing the output from Spark was very data dependent. I guess this is inevitable since Spark uses IRLS [Wikipedia] as the solver and StatsModels was using l-BFGS. I was forced to use l-BFGS in StatsModels as I was otherwise "singular matrix" errors [SO].
But then, a passing data scientist helpfully pointed out that I was looking at the wrong StatsModel class (Logit in discreet_model.py not GLM in generalized_linear_model.py). Taking the GLM code, I got within 6% of StatsModels when both run on the same data. Could I do better? Well, first I rolled back the use of l-BFGS to the default solver (which is IRLS for both libraries) and removed a regularization parameter in the Spark code that had crept in from a copy-and-paste - oops. Now, the difference between the two was an impressive 4.5075472233299e-06. Banzai!
We may choose to have no regularization if we're looking for inferential statistics (which I am). This might lead to overfitting but that's OK. "Overfitting is predominantly an issue when building predictive models in which the goal is application to data not used to build the model itself... As far as other applications that are not predictive, overfitting is more secondary" [SO]
The Data
The size of data makes a difference. The more data, the lower the likelihood it is explained by the parameters. This is simply because the state space is bigger and that means there are more potentially wrong states.
Also, adding a bias of a single feature can change the log-likelihood significantly. Making a previously unimportant feature biased to a certain outcome 2/3 of the time reduced the LL in my data by an order of magnitude. Not surprisingly, if the data is constrained to a manifold, then it's more likely your model will find it.
Thursday, June 10, 2021
Notes on Regression Analysis
Regression Analysis
"A key goal of regression analysis is to isolate the relationship between each independent variable and the dependent variable. The interpretation of a regression coefficient is that it represents the mean change in the dependent variable for each unit change in an independent variable when you hold all of the other independent variables constant." [Multicollinearity in Regression Analysis]
Similarly, one-hot encoding by definition increases multicollinearity because if feature X has value 1, then I know that all the others have 0 "which can be problematic when you sample size is small" [1].
The linked article the describes how Variance Inflation Factors ("VIFs") can be used to identify multicollinearity.
"If you just want to make predictions, the model with severe multicollinearity is just as good!" [MiRA]
To remove or not?
The case for adding columns: Frost [1] has a good example on how excluding correlated features can give the wrong result. It describes how a study to test the health impact of coffee at first showed it was bad for you. However, this study ignored smokers. Since smokers are statistically more likely to be coffee drinkers, you can't exclude smoking from your study.
The case for removing columns: "P-values less than the significance level indicate that the term is statistically significant. When a variable is not significant, consider removing it from the model." [1]
Inferential Statistics
But what if we don't want to make a model? "Regression analysis is a form of inferential statistics [in which] p-values and coefficients are the key regression output." [1]
Note that the p-value is indicates whether we should reject the null hypothesis. It is not an estimate of how accurate our coefficient is. Even if the coefficient is large, if the p-value is also large "the observed difference ... might represent random error. If we were to collect another random sample and perform the analysis again, this [coefficient] might vanish." [1]
Which brings us back to multicollinearity as it "reduces the precision of the estimated coefficients, which weakens the statistical power of your regression model... [it] affects the coefficients and p-values."
["Statistical power in a hypothesis test is the probability that the test can detect an effect that truly exists." - Jim Frost]
[1] Regression Analysis, Jim Frost
Sunday, February 4, 2018
p-values
The dangers of relying on p-values are well known. "In most science journals, researchers report p-values without apology, and readers interpret them as evidence that the apparent effect is real. The lower the p-value, the higher their confidence in this conclusion." (ThinkStats).
A good example of the problem can be found here. But, in brief, imagine this:
- we test how effective are 1000 different drugs
- of which only 100 are truly efficacious and therefore the other 900 are not
- the p-value is a fairly typical 0.05 (5%)
- of the 900 useless drugs, 45 (=900*0.05) will appear efficacious when they are not.
- of the 100 efficacious drugs, 5 (=100*0.05) will appear useless when they are not.
We'll ignore 5 effective cures (false negatives) and think 45 are useful when they are not (false positives). Since only 100 are truly effective, these are large percentages in our errors.
From Machine Learning in Action (Manning)
Precision = TP/(TP+FP) . Precision tells us the fraction of records that were positive from the group that the classifier predicted to be positive.
Recall = TP/(TP+FN) . Recall measures the fraction of positive examples the classifier got right.
Given that our true positives cannot be better than 95 (as 5 were wrongly deemed ineffective drugs), our precision and recall would not be better than 0.68 and 0.95 in this case.
Saturday, April 8, 2017
Video games to statistical mechanics
A friend was writing a computer game in his spare time and wanted to speckle a sphere with texture uniformly over its surface. Idly, he asked our team how to do this. We came up with some naive ideas like taking the x,y and z co-ordinates from a uniform sample and normalizing them to make a vector or length r, the radius of the sphere. In Python, this would be:
import random, math, pylab, mpl_toolkits.mplot3d
x_list, y_list, z_list = [],[],[]
nsamples = 10000
for sample in xrange(nsamples):
x, y, z = random.uniform(-1.0, 1.0), random.uniform(-1.0, 1.0), random.uniform(-1.0, 1.0)
radius = math.sqrt(x ** 2 + y ** 2 + z ** 2)
x_list.append(x / radius)
y_list.append(y / radius)
z_list.append(z / radius)
fig = pylab.figure()
ax = fig.gca(projection='3d')
ax.set_aspect('equal')
pylab.plot(x_list, y_list, z_list, '+')
pylab.show()
but this is not quite uniformly distributed over the sphere. It produces a sphere like this:
![]() |
| Sphere with Cartesian co-ordinates taken from a uniform sample and then normalized |
Using polar co-ordinates and uniformly sampling over 2π doesn't make things better either. This:
for sample in xrange(nsamples):
phi, theta = random.uniform(0, 2.0) * math.pi, random.uniform(0, 2.0) * math.pi
x_list.append(math.cos(phi) * math.cos(theta))
y_list.append(math.cos(phi) * math.sin(theta))
z_list.append(math.sin(phi))
![]() |
| Sphere with polar co-ordinates taken from a uniform sample |
The solution involves Gaussian (a.k.a 'Normal') distributions, thus:
for sample in xrange(nsamples):
x, y, z = random.gauss(0.0, 1.0), random.gauss(0.0, 1.0), random.gauss(0.0, 1.0)
radius = math.sqrt(x ** 2 + y ** 2 + z ** 2)
x_list.append(x / radius)
y_list.append(y / radius)
z_list.append(z / radius)
Tuesday, February 14, 2017
Tweaking TF-IDF
TF-IDF is a simple statistic (with a few variants) used in information retrieval within a corpus of text. The funny thing is that although it works well, nobody seems to be sure why. Robertson says in On theoretical arguments for IDF:
"The number of papers that start from the premise that IDF is a purely heuristic device and ends with a claim to have provided a theoretical basis for it is quite startling."
The simplest expression for TF-IDF is:
(TF) . (IDF) = (ft,d) . (log (N / |{d∈D : t∈d}|))
where t is a "term" (word, ngram etc); d is a document in corpus D; N is the total number of documents in corpus D; and ft,d is the frequency of term t in document d. (Often the term frequency component is actually a function of the raw ft,d) . "The base of the logarithm is not in general important" (Robertson).
It has been mooted that the IDF component looks like the log of a probability. That is, if we define the number of documents with term ti to be ni then we could expect the probability of a given document containing ti to be:
P(ti) = P(ti occurs in d) = ni/N
and the IDF term looks like:
idf(ti) = - log P(ti)
The nice thing about this is that "if we assume the occurrences of different terms in documents are statistically independent, then addition is the correct thing to do with the logs":
idf(t1∧t2) = -log P(t1∧t2)
= -log(P(t1)P(t2))
= -(log P(t1) + log P(t2))
= idf(t1) + idf(t2)
Thus we can add IDF terms to give us a total score.
Implementations
Lucene used to use this implementation but appear to moving to something called BM25 with which they're getting good results. I tried BM25 but it was unfortunately prone to over-linking my documents compared to the classic formula.
So, I returned to the classic implementation but thought again about an approximation I had made. I had capped the maximum frequency of a given term to be 1000 simply for practical reasons of storage. All other terms are discarded as something that occurs too often is not likely to be useful in this particular domain. Note that I could not filter out a list of stopwords as lots of my corpus was not English. The hope was that stopwords would naturally drop out as part of this restriction. Imposing this limit still gave me over 90 million terms.
The result was the difference between the a rare term and the most common term that we record was not that great. Since I am comparing documents, the minimum document frequency for a term to be useful is 2 (a frequency of 1 obviously doesn't compare documents). If the maximum frequency is 1000 then the ratio of the IDFs would be:
log (N / 2) / log (N / 1000)
which for 90 million documents is only about 1.5. No wonder the results for my document comparisons were not great. Now, if I set N = 1000 + 1 (the +1 to avoid a nasty divide by 0), the ratio between the weighting for the rarest term and the most common is about 6218 which seems more reasonable. And sure enough, more documents seemed to match. (Caveat: I say "seemed to match" as I only examined a sample then extrapolated. There are far too many documents to say definitely that the matching is better).
Monday, July 18, 2016
The theory that dare not speak its name
![]() |
| From XKCD (https://xkcd.com/1132/) |
Bayes Theorem has been controversial for hundreds of years. In fact, books have been written on it (see here for a recent and popular one). One reason is that it depends on subjectivity. But Allen B Downey, author of Think Bayes (freely downloadable from here), thinks this is a good thing. "Science is always based on modeling decisions, and modeling decisions are always subjective. Bayesian methods make these decisions explicit, and that’s a feature, not a bug. But all hope for objectivity is not lost. Even if you and I start with different priors, if we see enough data (and agree on how to interpret it) our beliefs will converge. And if we don’t have enough data to converge, we’ll be able to quantify how much uncertainty remains." [1]
Bayesian Networks
Taking the examples from Probabilistic Graphical Models available at Coursera, imagine this scenario: a student wants a letter of recommendation which depends on their grades, intelligence, SAT scores and course difficulty like so:
The course uses SamIam but I'm going to use Figaro as it's written in Scala. Representing these probability dependencies then looks like this:
class ProbabilisticGraphicalModels {
implicit val universe = Universe.createNew()
val Easy = "Easy"
val Hard = "Hard"
val Dumb = "Dumb"
val Smart = "Smart"
val A = "A"
val B = "B"
val C = "C"
val GoodSat = "GoodSat"
val BadSat = "BadSat"
val Letter = "Letter"
val NoLetter = "NoLetter"
def chancesOfDifficultIs(d: Double): Chain[Boolean, String]
= Chain(Flip(d), (b: Boolean) => if (b) Constant(Hard) else Constant(Easy))
def chancesOfSmartIs(d: Double): Chain[Boolean, String]
= Chain(Flip(d), (b: Boolean) => if (b) Constant(Smart) else Constant(Dumb))
def gradeDistributionWhen(intelligence: Chain[Boolean, String] = defaultIntelligence,
difficulty: Chain[Boolean, String] = defaultDifficulty): CPD2[String, String, String]
= CPD(intelligence, difficulty,
(Dumb, Easy) -> Select(0.3 -> A, 0.4 -> B, 0.3 -> C),
(Dumb, Hard) -> Select(0.05 -> A, 0.25 -> B, 0.7 -> C),
(Smart, Easy) -> Select(0.9 -> A, 0.08 -> B, 0.02 -> C),
(Smart, Hard) -> Select(0.5 -> A, 0.3 -> B, 0.2 -> C)
)
def satDist(intelligence: Chain[Boolean, String] = defaultIntelligence): CPD1[String, String]
= CPD(intelligence,
Dumb -> Select(0.95 -> BadSat, 0.05 -> GoodSat),
Smart -> Select(0.2 -> BadSat, 0.8 -> GoodSat)
)
def letterDist(gradeDist: CPD2[String, String, String] = defaultGradeDist): CPD1[String, String]
= CPD(gradeDist,
A -> Select(0.1 -> NoLetter, 0.9 -> Letter),
B -> Select(0.4 -> NoLetter, 0.6 -> Letter),
C -> Select(0.99 -> NoLetter, 0.01 -> Letter)
)
.
.
And we can query it like so:
.
.
def probabilityOf[T](target: Element[T], fn: (T) => Boolean): Double = {
val ve = VariableElimination(target)
ve.start()
ve.probability(target, fn)
}
.
.
Finally, let's add some sugar so I can use it in ScalaTests:
.
.
val defaultDifficulty = chancesOfDifficultIs(0.6)
val easier = chancesOfDifficultIs(0.5)
val defaultIntelligence = chancesOfSmartIs(0.7)
val defaultGradeDist = gradeDistributionWhen(intelligence = defaultIntelligence, difficulty = defaultDifficulty)
val defaultLetterDist = letterDist(defaultGradeDist)
val defaultSatDist = satDist(defaultIntelligence)
def being(x: String): (String) => Boolean = _ == x
def whenGettingAnABecomesHarder(): Unit = defaultGradeDist.addConstraint(x => if (x == A) 0.1 else 0.9)
def whenTheCourseIsMoreLikelyToBeHard(): Unit = defaultDifficulty.addConstraint(x => if (x == Hard) 0.99 else 0.01)
def whenLetterBecomesLessLikely(): Unit = defaultLetterDist.addConstraint(x => if (x == Letter) 0.1 else 0.9)
def whenTheSatIsKnownToBeGood(): Unit = defaultSatDist.observe(GoodSat)
}
If we wanted to know the chances of receiving a letter of recommendation, we'd just have to run probabilityOf(letterDist, being(Letter)) (which equals about 0.603656). The interesting thing is what happens if we observe some facts - does this change the outcome?
The answer is: it depends.
If we observe the SAT score for an individual, then their probability of receiving the letter changes. For example, executing satDist.observe(GoodSat) means that the chancesOf method now returns a probability of about 0.712 (and similarly a BadSat reduces the probability to about 0.457).
The general idea is given in this screen grab:
![]() |
| From Daphne Koller's Probabilistic Graphical Models |
Probabilities can flow through nodes in this Bayesian network so X -> W -> Y and X <- W <- Y are not terribly surprising if we know nothing about the intermediate step (the column on the left hand side). Conversely, if we do know something about it, that's all we need and it doesn't matter what the first step does.
The V-Structure (X <- W -> Y) is more interesting. Here, we can use ScalaTest to demonstrate what's going on. The first case is when we have no observed evidence. Here, Koller tells us
"If I tell you that a student took a class and the class is difficult, does that tell you anything about the student's intelligence? And the answer is 'no'."And sure enough, Figaro agrees:
"probabilities" should {
"... W and all of its descendants are not observed."if it is observed, then the following is true:
"flow X -> W <- Y ('V-structure')" in new ProbabilisticGraphicalModels {
// Can difficulty influence intelligence via letter?
val smartBefore = probabilityOf(defaultIntelligence, being(Smart))
whenLetterBecomesLessLikely() // "this too activates the V-structure"
val smartAfter = probabilityOf(defaultIntelligence, being(Smart))
smartBefore should be > smartAfter
}
"I know the student got an A in the class, now I'm telling you that the class is really hard.does that change the probability of the distribution of the letter? No because ... the letter only depends on the grade.""Given evidence about Z" should {
"make no difference in (X -> W -> Y)" in new ProbabilisticGraphicalModels {
defaultGradeDist.observe(A)
val letterChanceBefore = probabilityOf(defaultLetterDist, being(Letter))
whenTheCourseIsMoreLikelyToBeHard()
val letterChanceAfter = probabilityOf(defaultLetterDist, being(Letter))
letterChanceBefore shouldEqual letterChanceAfter
}
"If I tell you that the student is intelligent, then there is no way the SAT can influence the probability influence in grade."
[1] Prof Allen B Downey's blog
[2] Daphne Koller, Coursera.
Sunday, June 15, 2014
Lies, Damned Lies and Performance Statistics
A new way of measuring performance
I've introduced HdrHistogram to my pet project, JStringServer. The advantages of HdrHistogram is that it keeps the results in constant memory with constant access time. The only cost is some approximation of results and since I'm only interested in means, standard deviations etc I can live with that.
HdrHistogram works by keeping an array of "buckets" which represent a range of values. Any given value that falls into a particular bucket merely increases that bucket's counter. This is much more memory efficient than, say Apache's JMeter that keeps every reading in memory.
The range of these buckets grow exponentially (well, there are sub-buckets but let's keep this simple). So, all results that are outliers are collected in just one or two buckets. This is very memory efficient as each bucket is a Java long each.
Means, standard deviations and all that
Most performance tools like Apache's JMeter will give you a mean and a standard deviation. You might have even heard that 68% of results are within a standard deviation of the mean, 95% within 2 standard deviations, 99.7% within 3 etc (see here for the rule). But this is only true for a normal distribution (the typical, bell-shaped curve).
The time it takes for a given measurement independent of all others will typically conform to a Poisson distribution. (See Mathematical Methods in the Physical Sciences by Mary Boas for a clear derivation of this formula from first principles.)
Results
Typically, I see:
Initiated 412759 calls. Calls per second = 10275. number of errors at client side = 0. Average call time = 10ms
ThreadLocalStopWatch [name=read, totalCallsServiced=412659, max time = 630ms, Mean = 6.125236575477573, Min = 0, standard deviation 8.263969133274433, Histogram with stepsize = 1. Approximate values: 791, 1822, 3524, 15650, 143582, 307867, 243841, 81151, 18447, 4716, 1395, 493, 314, 195, 106, 97, 82, 66, 35, 10, 6, 7, 10, 7, 12, 14, 8, 7, 4, 2]
ThreadLocalStopWatch [name=write, totalCallsServiced=412719, max time = 26ms, Mean = 0.08618696982692825, Min = 0, standard deviation 0.327317028847167, Histogram with stepsize = 1. Approximate values: 411831, 33604, 771, 238, 80, 32, 17, 8, 1, 1, 2, 4, 5, 3, 0, 0, 1, 1, 1, 1, 0, 0, 1, 2, 5, 8, 4, 0, 0, 0]
ThreadLocalStopWatch [name=connect, totalCallsServiced=412720, max time = 3005ms, Mean = 4.17902209730568, Min = 0, standard deviation 65.54450133531354, Histogram with stepsize = 1. Approximate values: 408721, 117052, 2056, 852, 282, 114, 57, 30, 15, 12, 10, 10, 15, 10, 3, 5, 4, 3, 2, 1, 3, 4, 2, 0, 1, 1, 0, 1, 1, 0]
ThreadLocalStopWatch [name=total, totalCallsServiced=410682, max time = 66808064ns, Mean = 6295825.973268021, Min = 854104, standard deviation 1089344.7561186429, Histogram with stepsize = 1333333. Approximate values: 145, 837, 1596, 25512, 273990, 93021, 12099, 2283, 520, 273, 126, 77, 80, 55, 11, 9, 5, 9, 10, 11, 15, 5, 3, 2, 3, 3, 0, 0, 4, 1, 2]
For something that approximates to a Poisson distribution, we'd expect the mean and standard deviation to be about the same. Since this is not true for the total time, perhaps this is not a Poisson distribution. The results for reading data does have these two values roughly the same so let's look at them.
Let's see if the distribution of the values conform to the Poisson distribution or even the Normal (aka Gaussian). In the R language:
require(graphics)
# Histogram of times. Each interval is 1ms starting from 1ms
xread <- c(791, 1822, 3524, 15650, 143582, 307867, 243841, 81151, 18447, 4716, 1395, 493, 314, 195, 106, 97, 82, 66, 35, 10, 6, 7, 10, 7, 12, 14, 8, 7, 4, 2)
readMean = 6.125236575477573
readSd = 8.263969133274433
readNum = 412659
n = readNum
x = xread
mean = readMean
sd = readSd
ac <- barplot(x, main="Actual Distribution", axes=TRUE)
axis(1, at=ac, labels=TRUE)
i <- 1:length(x)
expectedPoisson = n * dpois(i, lambda=mean)
expectedNorm = n * dnorm(i, mean=mean, sd=sd)
nm <- barplot(expectedNorm, main="Expected Normal Distribution")
axis(1, at=nm, labels=TRUE)
pn <- barplot(expectedPoisson, main="Expected Poisson Distribution")
axis(1, at=pn, labels=TRUE)
chisq.test(x, p=expectedPoisson, rescale.p=TRUE)
chisq.test(x, p=expectedNorm, rescale.p=TRUE)
The Central Limit Theorem
(Aside):
The means of a distribution form a normal distribution given enough runs even if that original distribution is not itself a normal distribution.
Let's look at plotting the Poisson distribution in R:
mu=1; sigma=10 # mean, standard deviation
n=5000 # Number of iterations
xbar=rep(0,n) # Holds the results of the iterations
for (i in 1:n) {
xbar[i]=mean(
rpois(5000, mu) # num. random variables = 5000, mu = means
)
}
par(mfrow = c(2, 1))
# Plot a typical Poisson distribution
hist(rpois(n, mu),prob=TRUE,breaks=15)
# Plot the results of n Poisson distributions
hist(xbar,prob=TRUE) #,xlim=c(70,130),ylim=c(0,0.1))
Gives this result:
![]() |
| A typical Poisson distribution and the mean of a Poisson over many iterations |






