Earn a 50% discount on the DP-600 certification exam by completing the Fabric 30 Days to Learn It challenge.
Hi
I am trying to create a query using M language that allows me to correct text in a certain column in a certain table using data from two columns in another table.
I have a table of bank transactions with a column for Description. Due to OCR errors, there are numerous errors in the text in that column. I have a second table called Replacements that has two columns: one for incorrect text called Find and one for corrected text called Replace. I want to create a recursive function that searches BankRecords[Description] column for the incorrect text in Replacements[Find] and if there is a match replace it with the text in Replacements [Replace]. There are two stumbling blocks I can't seem to get past: the first is I want the search for the incorrect text to begin at position 0 in the Descriptions column, and the second is that I am only replacing parts of the text, not the whole cell or the full text string.
I have based my code on code I found in John McDougal's Blog "How to Excel", specifically his 2019 article "Bulk Find And Replace In Power Query". Here's the code I'm using:
let BulkReplace = (DataTable as table, FindReplaceTable as table, DataTableColumn as list) =>
let
//Buffer the FindReplaceTable and remove the “Bank” column
BufferFRT = Table.Buffer(Table.RemoveColumns(FindReplaceTable, “Bank”),
//Convert the buffered FindReplaceTable to a buffered list using the List.Buffer and Table.ToRows
//functions so we can reference the list with an index number
FindReplaceList = List.Buffer(Table.ToRows(BufferFRT)),
//Count number of rows in the FindReplaceTable to determine
//how many iterations are needed
Counter = Table.RowCount(BufferFRT),
//Define a function to iterate over our list
//with the Table.ReplaceValue function
BulkReplaceValues = (DataTableTemp, n) =>
let
//Set length of replace string
ReplaceLength = Text.Length(FindReplaceList{n}{0})
DataColumnLength = Text.Length(DataTableColumn{n}{0})
//Replace values using nth item in FindReplaceList
ReplaceTable = Table.ReplaceValue(
DataTableTemp,
//set string from Find column as ‘oldValue’
FindReplaceList{n}{0},
//where Find column string matches start of Description column, set
//newValue equal to combination of Replace column string plus
//remainder of Description column string
If FindReplaceList{n}{0} = Text.Start(DataTableColumn{n}{0}, ReplaceLength)
Then
ReplaceString = Text.Combine(FindReplaceList{n}{1}, Text.Middle(DataTableColumn{n}{0}, ReplaceLength+1, DataColumnLength))
Else "",
Replacer.ReplaceText,
DataTableColumn
)
in
//if we are not at the end of the FindReplaceList
//then iterate through Table.ReplaceValue again
if n = Counter - 1
then ReplaceTable
else @BulkReplaceValues(ReplaceTable, n + 1),
//Evaluate the sub-function at the first row
Output = BulkReplaceValues(DataTable, 0)
in
Output
in
BulkReplace
If I imbed the BulkReplace function at the end of a query so that it reads: =BulkReplaceFunction(#"Reordered Columns", Replacements, {"Description"}), I get the error message: "Expression.Error: We cannot convert the value "Description" to type List."
If I invoke the BulkReplace function, so that it reads: =BulkReplaceFunction(BankRecords, Replacements, BankRecords[Description]), I get the error message: "Expression.Error: We cannot convert the value "#290 WINNERS" to type List." Note that "#290 WINNERS" is the value from first cell in my Description Column in the table BankRecords.
Thanks in advance for your help.
Solved! Go to Solution.
Hi, @prankin , why bother to use such an intimidating chunk of codes given that there already exists a native function List.Accumulate? You might want to try such a solution,
let
BulkReplaceFunction = (DataTable as table, FindReplaceTable as table, DataTableColumn as list) =>
let
BufferedFRT = List.Buffer(Table.ToRecords(FindReplaceTable)),
Substitution = List.Accumulate(//outer List.Accumulate for iteration
DataTableColumn,
Table.Buffer(DataTable),
(s, c) =>
Table.TransformColumns(
s, {{c, each List.Accumulate(//core function, inner List.Accumulate to find & replace
BufferedFRT, _, (x, y) => Text.Replace(x, y[Find], y[Replace])
)}}
)
)
in
Substitution,
Replacements = Table.FromRows(Json.Document(Binary.Decompress(Binary.FromText("fZGxDoIwEIZfpeJGcGhwcWxrSRtDSSvSGMLgQKKLC2H3WXw0n8S7Ahowcbn+33eXa5rWdRQznkdJhLVJAIUDghJAcgAoATJxBsIaUJtSOs7MAeQ3YyuZr3w9nkQ6LZhBmY9x7Cwnj7BIMLcHm7MPYJOS/21TVR409d4Hkfbd7d52HTg+RfSCKGLX+EgFRzB0U9vtzOwWE0quyEkGJS2EIK/tpW8XTsniZ07ZeFg24fwuTVOBQnMxiMKQUruwZT9G9JLAe/E/SJnB/zRv", BinaryEncoding.Base64), Compression.Deflate)), let _t = ((type nullable text) meta [Serialized.Text = true]) in type table [Find = _t, Replace = _t]),
BankRecords = Table.FromRows(Json.Document(Binary.Decompress(Binary.FromText("nZfLbuM2FIZf5TTpog0kmodXcSnL9FhTS05kx5k0mEUKGBhggBS9AV3OoxR9lD7KPMmQEikzmcyCBQz70LL9+T+Xn9TDwwVeFBdvH59OsPr15EJkXJRSKl2Vxhj3RvPhN/e8fHz6CP6jfIFiwSj18SUzFO7avrfD3i2VIrRyr+5RxjgE74uHC5YHEgFEXfw9IBJhdGm39mj9l5EoGVAxLrUmmo8onofCBZNR0w/HentrYdfDtr35cUJxPKPGuKwMoWJEiTyUWSCLqq7qZQdNvd/A3WIFd/UWuno4uAuMOrUBGePS5ZqIiSlzM8lplHfVDNDZbgfrYdcBZ1i6b5fhdUJyQednYYhUI1Nl68RZp13Cst1u4fq+O0Dd2Xcw2De323rwnEJol8xYzGTpY2Zc2Ue+zubLmb9u7uEw1P1+bYeJI0xhKkoofRmrQkpKJI7MKrONcIFmhrb9wQ7Luv/JodfQkTVpyH//THiGhUBGZPUiVrowihM6STb/v7WKnNZyVK3cEE1UpLlYNmf686d/wQ5tU/dg310Pdr9PKs3GjhrBY+Swkp2xmT6UNpjDOrkre73bt4eYY1G4i6Gq51DjM2imJ73QuncVbuphVcCy23mFZ84o87xU5hk2059SLMIrYCcqBSdLVRU0AWe6FV8wHp0DW04RDuuhXA+vGwdOfzByMeFmOpZvZJzB/fF4B3xYwfXh3lc5zNBZZBJqWqiEm+laDhvzzG/3be8beLe6hrUdN53XCls942W6lN/gosx5YuvVse4bO4qJxCny9pDAMu3JTQyP6lJ7cEs5g2QEiQSUaUSucmZW1TS72/4Aezsc28ZCs6mHN2kuMfJw5rFMB/LdoqOy5enpD+gef/94+tNfqojWkRRiB+OGEcMmWPb5h0VtDWzg5tJhqRpnMB1+ziUZ8+bNlVUEpx2FZRpOSsPy4Ua8TpNIjAwwv5VNWzbLtJkUZr4lzHVIAPEzKNNWXMH4PN7Nxn4H7sQV7HuiOPcwQRNqJJqF7nc7lg6nyUxLkcm5tflwevzrBJeIlRconwlERSINNTGBlm0kKtG3c/IuNfU/W4WDDjJFVDUZCBWCqJDJTANBenasZnNzBaYC7swCeUX1eLqQQZYolKoI95XSrNDIveARmWkjaZdsvtmQiogqoJTx8YjKNBIe5npktcgbOLb7GgZ2I95+zWSuWiYwjSZCT7cCmWYiEi9p3F3AoR0sdM1F3NhGJ/HntYmkKkFMIGU6CYYjqo8tuB0dGvrz379UVn49dIiGVKF2Ujlrcfc5778A", BinaryEncoding.Base64), Compression.Deflate)), let _t = ((type nullable text) meta [Serialized.Text = true]) in type table [Index = _t, #"Account Name" = _t, #"Account Number" = _t, #"Account Type" = _t, #"Branch Reference" = _t, Date = _t, Description = _t, Debit = _t, Credit = _t, Value = _t, Balance = _t]),
Invoking = BulkReplaceFunction(BankRecords, Replacements, {"Description"})
in
Invoking
As you can see, the very essential part of the definition of the function
BulkReplaceFunction = (DataTable as table, FindReplaceTable as table, DataTableColumn as list) =>
let
BufferedFRT = List.Buffer(Table.ToRecords(FindReplaceTable)),
Substitution = List.Accumulate(//outer List.Accumulate for iteration
DataTableColumn,
Table.Buffer(DataTable),
(s, c) =>
Table.TransformColumns(
s, {{c, each List.Accumulate(//core function, inner List.Accumulate to find & replace
BufferedFRT, _, (x, y) => Text.Replace(x, y[Find], y[Replace])
)}}
)
)
in
Substitution,
Thanks to the great efforts by MS engineers to simplify syntax of DAX! Most beginners are SUCCESSFULLY MISLED to think that they could easily master DAX; but it turns out that the intricacy of the most frequently used RANKX() is still way beyond their comprehension! |
DAX is simple, but NOT EASY! |
You could just do a join:
NewStep = Table.Join(PriorStep, {"Description"}, ReplacementTable, {"NameOfColumnOneOfReplacementTable"}, JoinKind.LeftOuter)
Then add a column:
NewColumn = Table.AddColumn(NewStep, "Final Column", each if [NameOfColumnOneOfReplacementTable] = null then [Description] else [NameOfColumnTwoReplacementTable], type text)
That'll do it!---Nate
Sorry @watkinnc . Your code will not work for me. It requires that the complete string in both columns referenced in the join to match. I'm only matching the first few characters of the complete string. I ran your solution through a larger sample of my data set, and it only corrected 94 out of 73,000 rows. Same with my other problem. I'm searching for bits of text in the "Description" column, and if there is a match, then I want to do the next steps.
Hi @watkinnc: except that it adds three new columns (Find, Replace, NewColumn). Getting rid of the first two new columns is not difficult. However, I don't was the corrected text in a new column, I want the incorrect text replaced in the old column. Yours is also slightly slower when processing 100K+ rows of data. However, your solution may solve another problem I've run into, which is categorizing each transaction based on a separate table of data.
Hi @AlB. I have added some rows of the BankRecords table, as you requested, plus some rows of the Replacements table. Thanks.
=Table.ReplaceValue(BankRecords,"","",(x,y,z)=>let a=Table.SelectRows(Replacements,each Text.Contains(x,[Find])){0}? in if a=null then x else Text.Replace(x,a[Find],a[Replace]),{"Description"})
Hi, @prankin , why bother to use such an intimidating chunk of codes given that there already exists a native function List.Accumulate? You might want to try such a solution,
let
BulkReplaceFunction = (DataTable as table, FindReplaceTable as table, DataTableColumn as list) =>
let
BufferedFRT = List.Buffer(Table.ToRecords(FindReplaceTable)),
Substitution = List.Accumulate(//outer List.Accumulate for iteration
DataTableColumn,
Table.Buffer(DataTable),
(s, c) =>
Table.TransformColumns(
s, {{c, each List.Accumulate(//core function, inner List.Accumulate to find & replace
BufferedFRT, _, (x, y) => Text.Replace(x, y[Find], y[Replace])
)}}
)
)
in
Substitution,
Replacements = Table.FromRows(Json.Document(Binary.Decompress(Binary.FromText("fZGxDoIwEIZfpeJGcGhwcWxrSRtDSSvSGMLgQKKLC2H3WXw0n8S7Ahowcbn+33eXa5rWdRQznkdJhLVJAIUDghJAcgAoATJxBsIaUJtSOs7MAeQ3YyuZr3w9nkQ6LZhBmY9x7Cwnj7BIMLcHm7MPYJOS/21TVR409d4Hkfbd7d52HTg+RfSCKGLX+EgFRzB0U9vtzOwWE0quyEkGJS2EIK/tpW8XTsniZ07ZeFg24fwuTVOBQnMxiMKQUruwZT9G9JLAe/E/SJnB/zRv", BinaryEncoding.Base64), Compression.Deflate)), let _t = ((type nullable text) meta [Serialized.Text = true]) in type table [Find = _t, Replace = _t]),
BankRecords = Table.FromRows(Json.Document(Binary.Decompress(Binary.FromText("nZfLbuM2FIZf5TTpog0kmodXcSnL9FhTS05kx5k0mEUKGBhggBS9AV3OoxR9lD7KPMmQEikzmcyCBQz70LL9+T+Xn9TDwwVeFBdvH59OsPr15EJkXJRSKl2Vxhj3RvPhN/e8fHz6CP6jfIFiwSj18SUzFO7avrfD3i2VIrRyr+5RxjgE74uHC5YHEgFEXfw9IBJhdGm39mj9l5EoGVAxLrUmmo8onofCBZNR0w/HentrYdfDtr35cUJxPKPGuKwMoWJEiTyUWSCLqq7qZQdNvd/A3WIFd/UWuno4uAuMOrUBGePS5ZqIiSlzM8lplHfVDNDZbgfrYdcBZ1i6b5fhdUJyQednYYhUI1Nl68RZp13Cst1u4fq+O0Dd2Xcw2De323rwnEJol8xYzGTpY2Zc2Ue+zubLmb9u7uEw1P1+bYeJI0xhKkoofRmrQkpKJI7MKrONcIFmhrb9wQ7Luv/JodfQkTVpyH//THiGhUBGZPUiVrowihM6STb/v7WKnNZyVK3cEE1UpLlYNmf686d/wQ5tU/dg310Pdr9PKs3GjhrBY+Swkp2xmT6UNpjDOrkre73bt4eYY1G4i6Gq51DjM2imJ73QuncVbuphVcCy23mFZ84o87xU5hk2059SLMIrYCcqBSdLVRU0AWe6FV8wHp0DW04RDuuhXA+vGwdOfzByMeFmOpZvZJzB/fF4B3xYwfXh3lc5zNBZZBJqWqiEm+laDhvzzG/3be8beLe6hrUdN53XCls942W6lN/gosx5YuvVse4bO4qJxCny9pDAMu3JTQyP6lJ7cEs5g2QEiQSUaUSucmZW1TS72/4Aezsc28ZCs6mHN2kuMfJw5rFMB/LdoqOy5enpD+gef/94+tNfqojWkRRiB+OGEcMmWPb5h0VtDWzg5tJhqRpnMB1+ziUZ8+bNlVUEpx2FZRpOSsPy4Ua8TpNIjAwwv5VNWzbLtJkUZr4lzHVIAPEzKNNWXMH4PN7Nxn4H7sQV7HuiOPcwQRNqJJqF7nc7lg6nyUxLkcm5tflwevzrBJeIlRconwlERSINNTGBlm0kKtG3c/IuNfU/W4WDDjJFVDUZCBWCqJDJTANBenasZnNzBaYC7swCeUX1eLqQQZYolKoI95XSrNDIveARmWkjaZdsvtmQiogqoJTx8YjKNBIe5npktcgbOLb7GgZ2I95+zWSuWiYwjSZCT7cCmWYiEi9p3F3AoR0sdM1F3NhGJ/HntYmkKkFMIGU6CYYjqo8tuB0dGvrz379UVn49dIiGVKF2Ujlrcfc5778A", BinaryEncoding.Base64), Compression.Deflate)), let _t = ((type nullable text) meta [Serialized.Text = true]) in type table [Index = _t, #"Account Name" = _t, #"Account Number" = _t, #"Account Type" = _t, #"Branch Reference" = _t, Date = _t, Description = _t, Debit = _t, Credit = _t, Value = _t, Balance = _t]),
Invoking = BulkReplaceFunction(BankRecords, Replacements, {"Description"})
in
Invoking
As you can see, the very essential part of the definition of the function
BulkReplaceFunction = (DataTable as table, FindReplaceTable as table, DataTableColumn as list) =>
let
BufferedFRT = List.Buffer(Table.ToRecords(FindReplaceTable)),
Substitution = List.Accumulate(//outer List.Accumulate for iteration
DataTableColumn,
Table.Buffer(DataTable),
(s, c) =>
Table.TransformColumns(
s, {{c, each List.Accumulate(//core function, inner List.Accumulate to find & replace
BufferedFRT, _, (x, y) => Text.Replace(x, y[Find], y[Replace])
)}}
)
)
in
Substitution,
Thanks to the great efforts by MS engineers to simplify syntax of DAX! Most beginners are SUCCESSFULLY MISLED to think that they could easily master DAX; but it turns out that the intricacy of the most frequently used RANKX() is still way beyond their comprehension! |
DAX is simple, but NOT EASY! |
Hi @CNENFRNL
Thanks for the post. It took me a while to fully understand what you did. I also had to slightly modify it because it was replacing the string in the Description column at any position in the string. I needed the replacement process to only replace beginning at the start of the string.
Here's my mod:
let
DataTable = BankRecords,
BufferedFRT = List.Buffer(Table.ToRecords(Replacements)),
Substitution = List.Accumulate(
{"Description"},
Table.Buffer(BankRecords),
(s, c) =>
Table.TransformColumns(
s, {{c, each List.Accumulate(
BufferedFRT, _, (x, y) =>
if Text.StartsWith(x, y[Find])
then
Text.Replace(x, y[Find], y[Replace])
else
x)}}
)
)
in
Substitution
This is so easy though!
You could just do a join:
NewStep = Table.Join(PriorStep, {"Description"}, ReplacementTable, {"NameOfColumnOneOfReplacementTable"}, JoinKind.LeftOuter)
Then add a column:
NewColumn = Table.AddColumn(NewStep, "Final Column", each if [NameOfColumnOneOfReplacementTable] = null then [Description] else [NameOfColumnTwoReplacementTable], type text)
---Nate
Here's a sample of the Find and Replace columns from the Replacements Table:
Find | Replace |
*ABM | ABM |
*CR | CR |
*EB | EB |
*FCY | FCY |
*INTERBANK | INTERBANK |
,ABM | ABM |
• ERICAN | AMERICAN |
•BM | ABM |
•STERCARD | MASTERCARD |
1 •STERCARD | MASTERCARD |
1NVVW | 1WWW |
3usiness | Business |
C H Q# | CHQ# |
C1-[Q4 | CHQ# |
C9Q# | CHQ# |
CHE! UE | CHEQUE |
Cheaue | CHEQUE |
CHEOUE | CHEQUE |
CHQ* | CHQ |
CHQ4 | CHQ# |
CI13C | CIBC |
CON TIRE | CDN TIRE |
E TER | E TFR |
Here's a anonymized table of data from BankRecords with some of the OCR errors I encounter in the Description column.
Index | Account Name | Account Number | Account Type | Branch Reference | Date | Description | Debit | Credit | Value | Balance |
1 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/14/2001 | #290 WINNERS | 66.08 | (66.08) | (66.08) | |
2 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 4/14/2000 | $ 11.497-ELEVE | 11.65 | (11.65) | (77.73) | |
3 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 1/25/2001 | (VALUE ON LIQ) | 11.31 | (11.31) | (89.04) | |
4 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/12/2000 | *ABM CASH W/D WAL MART | 201.40 | (201.40) | (290.44) | |
5 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 4/30/2001 | *CR MEMO FROM 321-123-321-123 | 340.00 | 340.00 | 49.56 | |
6 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/11/2000 | *EB BILL PYMT AMEX REGULAR | 3,479.05 | (3,479.05) | (3,429.49) | |
7 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/15/2000 | *FCY TRANSFER | 49,980.00 | 49,980.00 | 46,550.51 | |
8 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 11/19/2000 | *INTERBANK TRF M.F.C. | 21,412.58 | 21,412.58 | 67,963.09 | |
9 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/12/2000 | ,ABM CASH W/D WAL MART | 201.40 | (201.40) | 67,761.69 | |
10 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/25/2000 | • ERICAN EXPRESS REGULAR | 240.00 | (240.00) | 67,521.69 | |
11 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/11/2000 | •BM DEPOSIT | 4,000.00 | 4,000.00 | 71,521.69 | |
12 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/25/2000 | •STERCARD, BMO | 2,000.00 | (2,000.00) | 69,521.69 | |
13 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/25/2000 | 1 •STERCARD, BMO | 1,500.00 | (1,500.00) | 68,021.69 | |
14 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/23/2001 | 1I301 TFR-FR 321-123-321-123 | 100.00 | 100.00 | 68,121.69 | |
15 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 12/21/2001 | 1NVVW 3RD PTY DEP | 2,500.00 | 2,500.00 | 70,621.69 | |
16 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 2/2/2000 | 3USINESS ODP FEE | 10.00 | (2,000.00) | 68,621.69 | |
17 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 4/20/2001 | ABM CASH ADVANCE | 700.00 | (700.00) | 67,921.69 | |
18 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 9/13/2000 | ABM CASH W/D | 500.00 | (500.00) | 67,421.69 | |
19 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 1/29/2001 | ACCOUNT SERVICE CHARG | 10.00 | (10.00) | 67,411.69 | |
20 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 12/27/2000 | Bens Market | 18.77 | (18.77) | 67,392.92 | |
21 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/2/2001 | C H Q#00006 | 1,000.00 | 335.19 | 67,728.11 | |
22 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/2/2001 | C1-[Q400006 | 1,000.00 | 51.95 | 67,780.06 | |
23 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/2/2001 | C9Q#00006 | 1,000.00 | (50.00) | 67,730.06 | |
24 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 12/31/2001 | CHE! UE DEPOSIT | 8,191.95 | 171.72 | 67,901.78 | |
25 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 5/14/2001 | Cheaue #1186 | 5,000.00 | 16.20 | 67,917.98 | |
26 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 2/6/2001 | CHEOUE #702 | 85.00 | 126.68 | 68,044.66 | |
27 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 10/2/2000 | CHQ* 98 3700138078 | 250.00 | 4,668.34 | 72,713.00 | |
28 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/2/2001 | CHQ400006 | 1,000.00 | 56.48 | 72,769.48 | |
29 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 3/27/2001 | CI13C VISA R2Q4J6 | 1,000.00 | 27.99 | 72,797.47 | |
30 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 4/27/2000 | CON TIRE MC | 100.00 | (112.50) | 72,684.97 | |
31 | Jane Doe | 1234-55678-999 | Chq | Bank 1 | 1/15/2001 | E TER C0Zxb8E5 | 1,000.00 | (119.84) | 72,565.13 |
Hi @prankin
Can you show at least a few rows of the BankRecords table? In text-taular format so that the contents can be easily copied
Please mark the question solved when done and consider giving a thumbs up if posts are helpful.
Contact me privately for support with any larger-scale BI needs, tutoring, etc.
Cheers