Independent Verification of Enterprise Based Training (EBT)

Fuzzy-matches: Address of Industry Partner/Training Institute


##  0. Standardizes Start Date / End Date / Assessment Date to DD/MM/YYYY,
##     regardless of the original mixed formats.
##  1. Reads "7 EBT Summary Information Upto June 26_work.xlsx" (sheet
##     "Summary informatio (2)"), single header row.
##  2. Reads "5 upazila_district_division_name.xlsx" (sheet "Sheet1") which
##     has columns Division / District / Upazila.
##  3. Reads "4 asset_sector_wise_trade_occupation_list.xlsx" (sheet
##     "Sheet1") which has columns "Trade / Occupation Names" and
##     "Priority Sector/ISC".
##  4. For every row in the Summary file:
##       a. Fuzzy-matches "Address of Industry Partner/Training Institute "
##          against Upazila names first, then District, then Division.
##          Most specific match (Upazila) wins if found; otherwise it falls
##          back to District, then Division. If nothing matches well enough,
##          it writes "Not found".
##       b. Matches "Name of Occupation" against "Trade / Occupation Names"
##          (allowing partial / substring matches in either direction) and
##          pulls back the corresponding "Priority Sector/ISC" value. If
##          nothing matches, it writes "Not matched".
##  5. Saves everything (original columns + 2 new columns) to a new xlsx file.


## ---- 0. Install/load required packages --------------------------------
required_pkgs <- c("readxl", "writexl", "dplyr", "stringr", "stringdist", "lubridate")
to_install <- required_pkgs[!required_pkgs %in% installed.packages()[, "Package"]]
if (length(to_install) > 0) install.packages(to_install)

library(readxl)
library(writexl)
library(dplyr)
library(stringr)
library(stringdist)
library(lubridate)


## ---- 1. FILE PATHS — edit these three lines ----------------------------
folder <- "D:/Munem/All Projects/2026/DTE/10. Sampling/7. Sampling Sixth round"

summary_path <- file.path(folder, "7 EBT Summary Information Upto June 26_work.xlsx")
geo_path     <- file.path(folder, "5 upazila_district_division_name.xlsx")
sector_path  <- file.path(folder, "4 asset_sector_wise_trade_occupation_list.xlsx")

output_path  <- file.path(folder, "7 EBT Summary Information Upto June 26_Matched.xlsx")


## ---- 2. Tuning parameter -------------------------------------------------

MAX_NORM_DIST <- 0.25


## ---- 3. Load the data ----------------------------------------------------
## After loading, check colnames(summary_df) — if the columns printed
## don't look right, adjust "skip" (try skip = 1 or skip = 2).
summary_df <- read_excel(summary_path, sheet = "Summary informatio (2)")
geo_df     <- read_excel(geo_path,     sheet = "Sheet1")
sector_df  <- read_excel(sector_path,  sheet = "Sheet1")

## Trim whitespace from column names — Excel headers sometimes have hidden
## leading/trailing spaces, and readxl doesn't always preserve them
## consistently, which caused a silent NULL-column bug last time.
colnames(summary_df) <- trimws(colnames(summary_df))
colnames(geo_df)     <- trimws(colnames(geo_df))
colnames(sector_df)  <- trimws(colnames(sector_df))

## Drop trailing blank columns some Excel exports leave behind (...46, ...47, etc.)
blank_cols <- str_detect(colnames(summary_df), "^\\.\\.\\.[0-9]+$")
if (any(blank_cols)) summary_df <- summary_df[, !blank_cols]

cat("Summary file columns:\n");  print(colnames(summary_df))
cat("\nGeo lookup columns:\n");  print(colnames(geo_df))
cat("\nSector lookup columns:\n"); print(colnames(sector_df))


## ---- 4. Text cleaning helper ----------------------------------------------
clean_text <- function(x) {
  x %>%
    str_to_lower() %>%
    str_replace_all("[^a-z0-9\\s]", " ") %>%   # strip punctuation/numbers' noise chars
    str_squish()                                # collapse extra whitespace
}


## =========================================================================
##  TASK 0 — Standardize Start Date / End Date / Assessment Date
## =========================================================================
##  dates arrive in mixed formats (e.g. 18/04/2026, 20 Apr 2026, 11-04-2026, 25.04.2026, 5 April 2026) — and some cells are already real
##  Excel dates while others are text, so after read_excel the column can contain a mix of ISO-style strings (from real dates) and the original messy text (from text cells). This handles both in one pass.
##  Dates are kept as REAL Date values (not text) so Excel treats them as actual dates — sortable, usable in date-difference formulas, and filterable by date — rather than as plain text that merely looks like a date. Excel will display them using your default date format.

clean_dates <- function(x) {
  x_chr <- trimws(as.character(x))
  as_date(parse_date_time(x_chr, orders = c("ymd", "dmy", "mdy"), quiet = TRUE))
}

date_cols <- c("Start Date", "End Date", "Assessment Date")

for (col in date_cols) {
  if (!col %in% colnames(summary_df)) {
    cat(sprintf("WARNING: column '%s' not found — skipping date cleaning for it.\n", col))
    next
  }
  parsed <- clean_dates(summary_df[[col]])
  n_failed <- sum(is.na(parsed) & !is.na(summary_df[[col]]) & trimws(as.character(summary_df[[col]])) != "")
  if (n_failed > 0) {
    cat(sprintf("NOTE: %d value(s) in '%s' could not be parsed and will be blank — check these rows manually:\n", n_failed, col))
    bad_rows <- which(is.na(parsed) & !is.na(summary_df[[col]]) & trimws(as.character(summary_df[[col]])) != "")
    print(summary_df[bad_rows, c("Sl.", col)])
  }
  summary_df[[col]] <- parsed
}


## =========================================================================
##  TASK 1 — Address -> Division (fuzzy, most-specific-level-wins)
## =========================================================================

geo_df <- geo_df %>%
  mutate(
    Division = str_squish(as.character(Division)),
    District = str_squish(as.character(District)),
    Upazila  = str_squish(as.character(Upazila))
  )

upazila_lookup <- geo_df %>%
  distinct(Upazila, District, Division) %>%
  filter(!is.na(Upazila), Upazila != "") %>%
  mutate(Upazila_clean = clean_text(Upazila))

district_lookup <- geo_df %>%
  distinct(District, Division) %>%
  filter(!is.na(District), District != "") %>%
  mutate(District_clean = clean_text(District))

division_lookup <- geo_df %>%
  distinct(Division) %>%
  filter(!is.na(Division), Division != "") %>%
  mutate(Division_clean = clean_text(Division))


## Build all contiguous word-windows of length n from a vector of words
ngram_windows <- function(words, n) {
  if (length(words) < n) return(character(0))
  sapply(1:(length(words) - n + 1), function(i) paste(words[i:(i + n - 1)], collapse = " "))
}

## For one address (already cleaned) and a vector of candidate place names
## (already cleaned), find the candidate with the smallest normalized edit
## distance against any equal-length word-window of the address.
best_fuzzy_match <- function(address_clean, candidates) {
  candidates <- unique(candidates[!is.na(candidates) & candidates != ""])
  addr_words <- str_split(address_clean, "\\s+")[[1]]
  addr_words <- addr_words[addr_words != ""]
  if (length(addr_words) == 0 || length(candidates) == 0) {
    return(list(match = NA_character_, score = Inf))
  }
  
  best_name  <- NA_character_
  best_score <- Inf
  
  for (cand in candidates) {
    cand_words <- str_split(cand, "\\s+")[[1]]
    n <- length(cand_words)
    windows <- ngram_windows(addr_words, n)
    if (length(windows) == 0) next
    dists <- stringdist(cand, windows, method = "lv")
    norm_dists <- dists / nchar(cand)
    min_norm <- min(norm_dists)
    if (min_norm < best_score) {
      best_score <- min_norm
      best_name  <- cand
    }
  }
  list(match = best_name, score = best_score)
}

## Main per-address matcher: tries Upazila, then District, then Division.
match_address_to_division <- function(raw_address) {
  if (is.na(raw_address) || str_trim(raw_address) == "") {
    return(c(Matched_Division = "Not found", Matched_Level = NA, Matched_Place_Name = NA))
  }
  addr_clean <- clean_text(raw_address)
  
  ## Level 1: Upazila (most specific)
  res <- best_fuzzy_match(addr_clean, upazila_lookup$Upazila_clean)
  if (!is.na(res$match) && res$score <= MAX_NORM_DIST) {
    row <- upazila_lookup %>% filter(Upazila_clean == res$match) %>% slice(1)
    return(c(Matched_Division = row$Division, Matched_Level = "Upazila", Matched_Place_Name = row$Upazila))
  }
  
  ## Level 2: District
  res <- best_fuzzy_match(addr_clean, district_lookup$District_clean)
  if (!is.na(res$match) && res$score <= MAX_NORM_DIST) {
    row <- district_lookup %>% filter(District_clean == res$match) %>% slice(1)
    return(c(Matched_Division = row$Division, Matched_Level = "District", Matched_Place_Name = row$District))
  }
  
  ## Level 3: Division
  res <- best_fuzzy_match(addr_clean, division_lookup$Division_clean)
  if (!is.na(res$match) && res$score <= MAX_NORM_DIST) {
    row <- division_lookup %>% filter(Division_clean == res$match) %>% slice(1)
    return(c(Matched_Division = row$Division, Matched_Level = "Division", Matched_Place_Name = row$Division))
  }
  
  return(c(Matched_Division = "Not found", Matched_Level = NA, Matched_Place_Name = NA))
}


## ---- Apply to every row of the summary file -------------------------------
address_col <- trimws("Address of Industry Partner/Training Institute")
cat(sprintf("\nMatching %d addresses to Division (this can take a little while)...\n", nrow(summary_df)))

address_results <- t(sapply(summary_df[[address_col]], match_address_to_division))
summary_df$Matched_Division     <- address_results[, "Matched_Division"]
summary_df$Matched_Level         <- address_results[, "Matched_Level"]
summary_df$Matched_Place_Name    <- address_results[, "Matched_Place_Name"]


## =========================================================================
##  TASK 2 — Occupation -> Sector (substring match, either direction)
## =========================================================================

trade_col  <- "Trade / Occupation Names"
sector_col <- "Priority Sector/ISC"

sector_df <- sector_df %>%
  mutate(Trade_clean = clean_text(.data[[trade_col]]))

match_occupation_to_sector <- function(raw_occupation) {
  if (is.na(raw_occupation) || str_trim(raw_occupation) == "") return("Not matched")
  occ_clean <- clean_text(raw_occupation)
  if (occ_clean == "") return("Not matched")
  
  hits <- sector_df %>%
    filter(
      str_detect(Trade_clean, fixed(occ_clean)) |
        str_detect(occ_clean, fixed(Trade_clean))
    )
  
  if (nrow(hits) > 0) {
    return(paste(unique(hits[[sector_col]]), collapse = "; "))
  } else {
    return("Not matched")
  }
}

occupation_col <- trimws("Name of Occupation")
cat(sprintf("Matching %d occupations to Sector...\n", nrow(summary_df)))

summary_df$Matched_Sector <- sapply(summary_df[[occupation_col]], match_occupation_to_sector)


## =========================================================================
##  5. Save the result
## =========================================================================
write_xlsx(summary_df, output_path)
cat(sprintf("\nDone. Output written to: %s\n", output_path))


Fuzzy-matches: Trade, Sector & Occupation


##  1. Reads "7.3 EBT Summary Information Upto June 26_Matched.xlsx"
##     (sheet "Sheet1").
##  2. Reads "8 Trade or occupation wsie sector from fifth round
##     cleaning.xlsx" (sheet "Sheet1"), which has columns "Trade" and
##     "Sector".
##  3. For every row in the Summary file, matches "Name of Occupation"
##     against "Trade" using an EXACT match — but trimmed of extra
##     whitespace and case-insensitive (so "Tailoring " matches
##     "tailoring"). This is a separate, additional check from the
##     existing "Matched_Sector" column already in the file (which came
##     from a different, substring-based match against a different list)
##     — that column is left untouched.
##  4. Adds ONE new combined column, "Trade_Sector_Match":
##       - If matched:      "Matched: <Sector Name>"
##       - If not matched:  "Not matched"
##  5. Saves everything to a NEW file: "7.4 EBT Summary Information Upto
##     June 26_Matched2.xlsx" (the original "7.3 ..." file is left
##     untouched).


## ---- 0. Install/load required packages --------------------------------
required_pkgs <- c("readxl", "writexl", "dplyr", "stringr")
to_install <- required_pkgs[!required_pkgs %in% installed.packages()[, "Package"]]
if (length(to_install) > 0) install.packages(to_install)

library(readxl)
library(writexl)
library(dplyr)
library(stringr)


## ---- 1. FILE PATHS -------------------------------------
folder <- "D:/Munem/All Projects/2026/DTE/10. Sampling/7. Sampling Sixth round"

summary_path <- file.path(folder, "7.3 EBT Summary Information Upto June 26_Matched.xlsx")
trade_path   <- file.path(folder, "8 Trade or occupation wsie sector from fifth round cleaning.xlsx")

output_path  <- file.path(folder, "7.4 EBT Summary Information Upto June 26_Matched2.xlsx")


## ---- 2. Load the data ----------------------------------------------------
summary_df <- read_excel(summary_path, sheet = "Sheet1")
trade_df   <- read_excel(trade_path,   sheet = "Sheet1")

## Trim whitespace from column names, in case of hidden leading/trailing
## spaces (this bit us last time with the address column).
colnames(summary_df) <- trimws(colnames(summary_df))
colnames(trade_df)   <- trimws(colnames(trade_df))

cat("Summary file columns:\n"); print(colnames(summary_df))
cat("\nTrade/Sector lookup columns:\n"); print(colnames(trade_df))

occupation_col <- "Name of Occupation"
trade_col      <- "Trade"
sector_col     <- "Sector"

stopifnot(occupation_col %in% colnames(summary_df))
stopifnot(trade_col %in% colnames(trade_df))
stopifnot(sector_col %in% colnames(trade_df))


## ---- 3. Build a clean lookup key (trimmed, lowercased) --------------------
trade_df <- trade_df %>%
  mutate(Trade_key = str_squish(str_to_lower(as.character(.data[[trade_col]]))))

## Warn if the lookup file itself has duplicate Trade names mapping to
## different sectors — worth knowing about before trusting the match.
dupe_check <- trade_df %>%
  filter(Trade_key != "") %>%
  group_by(Trade_key) %>%
  summarise(n_distinct_sectors = n_distinct(.data[[sector_col]]), .groups = "drop") %>%
  filter(n_distinct_sectors > 1)

if (nrow(dupe_check) > 0) {
  cat(sprintf("\nWARNING: %d Trade name(s) in the lookup file map to more than one Sector — check these:\n", nrow(dupe_check)))
  print(dupe_check)
}

## Keep first sector for each unique Trade key (after the warning above)
trade_lookup <- trade_df %>%
  filter(Trade_key != "") %>%
  distinct(Trade_key, .keep_all = TRUE) %>%
  select(Trade_key, !!sector_col := all_of(sector_col))


## ---- 4. Match ----------------------------------------------------------
summary_df <- summary_df %>%
  mutate(Occupation_key = str_squish(str_to_lower(as.character(.data[[occupation_col]])))) %>%
  left_join(trade_lookup, by = c("Occupation_key" = "Trade_key")) %>%
  mutate(
    Trade_Sector_Match = ifelse(
      !is.na(.data[[sector_col]]),
      paste0("Matched: ", .data[[sector_col]]),
      "Not matched"
    )
  ) %>%
  select(-Occupation_key, -all_of(sector_col))


## ---- 5. Save the result ---------------------------------------------------
write_xlsx(summary_df, output_path)

n_matched <- sum(summary_df$Trade_Sector_Match != "Not matched")
cat(sprintf(
  "\nDone. %d of %d rows matched.\nOutput written to: %s\n",
  n_matched, nrow(summary_df), output_path
))




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • Independent Verification of Enterprise Based Training (EBT)
  • Independent Verification of Enterprise Based Training (EBT)
  • Does sustainable industrialization reduce climate vulnerability?
  • Gender analysis of Waste Value Chain in Chattogram, Cox’s Bazar & Tangail
  • Does sustainable industrialization reduce climate vulnerability?