Here is an example of a function to produce simple regression tables for Firth’s logistic regression models. It includes the estimates (exponentiated to odds ratios by default), plus 95% confidence intervals and the p values from the penalised likelihood ratio tests. You can tweak the rounding and output as Markdown if desired. It can’t handle functions in the model formula (e.g. + factor(sex) +) but you can usually do that sort of thing outside the model call. Example table beneath the code.

library(data.table)
library(epiDisplay)  # for Oswego outbreak data set
library(knitr)
library(logistf)
 
data(Oswego)
setDT(Oswego)
 
options(scipen = 6)  # avoid scientific notation for small numbers
options(knitr.kable.NA = '')  # avoid NA's appearing in tables
 
# Add another level to sex and set "M" as the referent
Oswego[sample(nrow(Oswego), 10), sex := 'Other']
Oswego[, sex := relevel(factor(sex), ref = 'M')]
 
# Fit a model
model_firth <-
  logistf(ill ~ vanilla + bakedham + spinach + sex, data = Oswego)
 
# Create a function to produce regression table
logistf_table <- function(logistf_model,
                          exponentiate = TRUE,
                          rounding = 2,
                          p.val.digits = 3,
                          md = FALSE) {
  # Get the coefficients
  model_coef <-
    coef(logistf_model) |> as.data.table(keep.rownames = TRUE)
  # Get the confidence intervals
  model_confint <-
    confint(logistf_model) |> as.data.table(keep.rownames = TRUE)
  # Join the coeffients to the confidence intervals (easy)
  model_coef_confint <- model_confint[model_coef, on = c(rn = 'V1')]
  # Get the penalised LRT p values
  model_lrt <-
    drop1(logistf_model) |> as.data.table(keep.rownames = TRUE)
  # Join it all together (difficult)
  # Find the matches
  matches <-
    sapply(model_lrt$rn, function(x)
      grep(paste0('^', x), model_coef_confint$rn, value = TRUE),
      simplify = FALSE,
      USE.NAMES = TRUE)
  # Add the matches to a list column
  model_lrt[, matches := matches[rn]]
  # Expand the list column
  model_lrt <-
    model_lrt[, unlist(matches), .(rn, ChiSq, df, `P-value`)]
  # Join it all up now we have a common field
  final_table <- model_lrt[model_coef_confint, on = c(V1 = 'rn')]
  # Remove variable names from coefficient names
  final_table[, V1 := gsub(paste0('^', rn), '', V1), by = 1:nrow(final_table)]
  # Remove duplicate presentation of p values and variable names
  final_table[duplicated(rn), `:=`(
    `P-value` = NA,
    df = NA,
    ChiSq = NA,
    rn = NA
  )]
  # Tidy up table
  final_table <- final_table[, .(
    variable = rn,
    value = V1,
    estimate = V2,
    `Lower 95%`,
    `Upper 95%`,
    ChiSq,
    df,
    P.value = format.pval(`P-value`, p.val.digits)
  )]
  # Exponentiate to odds ratios by default
  if (exponentiate) {
    final_table[,
                `:=`(
                  estimate = exp(estimate),
                  `Lower 95%` = exp(`Lower 95%`),
                  `Upper 95%` = exp(`Upper 95%`)
                )]
  }
  # Round to 2 by default
  if (!is.null(rounding)) {
    final_table[,
                `:=`(
                  estimate = round(estimate, rounding),
                  `Lower 95%` = round(`Lower 95%`, rounding),
                  `Upper 95%` = round(`Upper 95%`, rounding)
                )]
  }
  # Output as Markdown if required
  if (md) {
    kable(final_table)
  } else {
    final_table[]
  }
}
 
# Run the function
logistf_table(model_firth)
# Presents variables in same order provided to original model formula
# NB: this can't handle terms in the model formula such as " + factor(sex) + "
# so do this outside the model formula
 
# Change rounding
logistf_table(model_firth, rounding = 1, p.val.digits = 2)
# Check out the pval_string function in the pixiedust package for more control on p value presentation
 
# Or output as Markdown
logistf_table(
  model_firth,
  rounding = 2,
  p.val.digits = 3,
  md = TRUE
)
 
# Now works also with one predictor models! (thanks Jo!)
model_firth2 <-
  logistf(ill ~ sex, data = Oswego)
logistf_table(model_firth2)
 
# Multiple one predictor models
xvars <-
  c(
    'sex',
    'bakedham',
    'spinach',
    'mashedpota',
    'cabbagesal',
    'jello',
    'rolls',
    'brownbread',
    'milk',
    'coffee',
    'water',
    'cakes',
    'vanilla',
    'chocolate',
    'fruitsalad'
  )
sva_list <- lapply(xvars, function(x) {
  model_formula <- reformulate(x, response = 'ill')
  model_fit <- logistf(model_formula, data = Oswego)
  logistf_table(model_fit)
})
rbindlist(sva_list)
variablevalueestimateLower 95%Upper 95%ChiSqdfP.value
(Intercept)0.100.020.44NA
vanillaTRUE19.675.7190.8526.515565410.000000261
bakedhamTRUE1.720.3111.420.372269110.542
spinachTRUE0.530.082.910.504025410.478
sexF3.050.8911.933.160642120.206
Other2.030.3814.62NA